FeaturesSettings (설정)설정 7서브 (/settings)

운영사 설정 — /settings (7서브 탭 셸)

SPEC #128 정합 (FE-only·BE 0) — PRD Page 18 7서브 라우팅 + 탭 셸 + placeholder 스캐폴딩. 18-7 약관(SPEC #023)·18-1 내 프로필(SPEC #130)·18-3 장르·무드 옵션(SPEC #132) 내실 완료, 나머지 4서브는 ComingSoon 골격(내실은 각 별도 SPEC).

Overview

운영사(OPERATOR) 백오피스 설정 영역. PRD Page 18 의 7개 서브를 상단 라우트 탭으로 묶은 공유 셸이다. 사이드바 하단 “설정” 항목(/settings)으로 진입하면 첫 탭 /settings/profile 로 redirect 된다.

시안 출처: 탭 셸은 시안 부재 — 검증된 라우트 탭 관용구(audit/audit-tabs.tsx)를 미러한 settings-tabs.tsx. 6 placeholder 는 기존 ComingSoon(atom-grounded) 재사용. 새 색/아이콘 추측 없음([[feedback_design_only_from_handoff]]).

구조

  • (protected)/settings/layout.tsx — 공유 셸. (protected)/layout 의 ops 셸(topbar/sidebar/ 인증 가드) 하위에서 <SettingsTabs/> 를 한 번만 두고 그 아래 각 서브 page 본문을 렌더한다. 각 서브 page(약관 화면·ComingSoon)는 자체 <PageHeader> 를 그대로 가지므로 layout 은 탭만 얹는다.
  • (protected)/settings/settings-tabs.tsxnext/link 기반 라우트 탭. 활성 표시는 링크 표준 aria-current="page" + 좌측 primary 도트(audit 탭 시각 미러). isActive 는 정확 일치 + “href + 슬래시” 접두 매칭으로 sub-route 도 활성 처리. 탭 순서는 PRD 번호순(1→7).
  • (protected)/settings/page.tsxredirect("/settings/profile")(번호순 첫 탭, SPEC #128 D2).

7서브 (PRD Page 18 · 번호순 탭)

#slug상태
18-1내 프로필/settings/profile내실 완료(SPEC #130) — 본인 정보(이름·이메일·역할 read) + 비밀번호 변경. 상세
18-2알림 설정/settings/notificationsTTS 연결 확인(SPEC #134) — 안내방송(TTS) smoke-test 버튼. 이메일·시스템 알림 토글은 추후. 상세
18-3장르·무드 옵션/settings/music-options내실 완료(SPEC #132) — 장르(GENRE)·무드(MOOD) 태그 옵션 CRUD(추가·수정·삭제=soft). 상세
18-4시스템 정책/settings/system-policiesComingSoon — 18-7 /settings/policies 충돌 회피 분리 slug(SPEC #128 D4)
18-5신탁 재생 로그/settings/trust-playback-logs내실 완료(SPEC #176 · #172 FU-3) — 플랫폼 전체 재생 로그 조회(기간·본사·매장 cascading·신탁 토글) + 서버 CSV 내보내기. 상세
18-6LLM 자동멘트/settings/llm-mentsComingSoon — LLM 가드레일, LLM 인프라 의존(별도 SPEC)
18-7약관 버전 관리/settings/policies기존 구현(SPEC #023·#033·#038) — 탭으로 편입(동작 불변). 상세
+1이메일 템플릿/settings/email-templates내실 완료(SPEC #146) — 계정·인증 이메일 4종 미리보기·테스트 발송(read-only). PRD Page 18 7서브 밖 추가 도구 탭. 상세

18-1 내 프로필 (/settings/profile, SPEC #130)

#128 스캐폴딩한 ComingSoon 을 내실화. 운영자 본인 계정 정보(read) + 비밀번호 변경 2 섹션. FE-only · BE 0 — 신규 endpoint 없이 기존 자산만 재사용한다.

시안 출처: 전용 시안 부재 — 점장 #100 store-profile-client.tsx·본사 #099 의 DescCard + ChangePasswordSection idiom 을 정확 미러로 atom-grounded 합성. 시안 도착 시 정합 교체 ([[feedback_design_only_from_handoff]]).

  • 본인 정보(read-only)useMe()(/api/auth/me BFF → 단일 소스 MeResponse) 응답을 DescCard 3행으로 노출: 이름(name, @nullable → null 이면 이메일(email역할 (roleOPERATOR “운영자” 등 한글 라벨). 운영자 me read endpoint 신설 없이 기존 세션 me 소스 재사용(BE 0). me 로딩 중 스켈레톤, me 실패 시 danger 배너(비번 변경 섹션은 me 와 무관하게 계속 노출). 이름 편집은 v1 제외(read-only) — 운영자 이름 편집 정책 확인 전 과설계 회피(후속).
  • 비밀번호 변경@/components/change-password-section(admin). 본사 #099·점장 #100 의 공용 ChangePasswordSection(apps/space) 과 폼 로직(현재/새/새 확인 state·검증·BFF reseal·코드별 에러 매핑·helper·a11y)을 1:1 미러. apps/space 패키지는 admin 앱에서 직접 import 불가(별도 앱)라 동일 idiom 을 admin 에 미러 추출했다. 같은 BFF route(POST /api/auth/change-password)를 호출해 reseal 로 같은 세션을 유지한다 — 강제 변경 /onboarding/change-password 와 달리 성공 후 destination redirect 없이 설정 화면에 머무르며 성공 배너만 표시. 검증은 backend OpenAPI ChangePasswordRequest 8~100자 제약과 정합. NO_SESSION·AUTH_UNAUTHENTICATED/login 으로 이동.

구조: page.tsx(server, client 마운트만) → settings-profile-client.tsx("use client", useMe + DescCard + ChangePasswordSection). 보호 라우트 가드는 상위 (protected)/layout me 가드.

테스트: settings-profile-client.test.tsx(me read 표시·null ·me 실패 배너+비번 섹션 유지· 비번 섹션 동반 렌더), change-password-section.test.tsx(testid·검증 4종·성공 reseal·에러 매핑· NO_SESSION redirect·네트워크 실패).

18-2 알림 설정 (/settings/notifications, SPEC #134)

#128 스캐폴딩한 ComingSoon 을 일부 내실화 — 안내방송(TTS) 연결 확인(smoke-test). TTS 가 안내방송을 구동하므로 알림/안내 설정 맥락에 둔다. 이메일·시스템 알림 수신 토글은 알림 인프라 의존으로 추후(별도 SPEC).

  • [TTS 연결 확인](tts-smoke-test-btn) → useRunTtsSmokeTest(POST /api/v1/admin/tts/smoke-test, OPERATOR). 고정 텍스트(voice SHEAN·NORMAL)로 부작용 없는 진단 합성 1회(blob·DB 미저장).
  • 성공(tts-smoke-test-success, Banner success): voice · 소요 elapsedMsms · 오디오 KB · 길이(durationSeconds) 표시.
  • 실패(tts-smoke-test-error, Banner danger): 503 TTS_TOKEN_NOT_CONFIGURED → “TTS 토큰이 설정되지 않았습니다. Render 환경변수 TYPECAST_API_TOKEN 을 확인해주세요.” · 502 TTS_SYNTHESIS_FAILED → “음성 합성에 실패했습니다. 잠시 후 다시 시도하거나 Typecast 연동 상태를 확인해주세요.” (code 우선 + status fallback). 운영자 수동 전용(quota 보호 — 자동 폴링 없음).
  • 응답 타입은 generated TtsSmokeTestResponse 단일 소스. 신규 env: TYPECAST_API_TOKEN(Render).

테스트: notifications-client.test.tsx(버튼 렌더·성공 voice/소요/크기 표시·503 환경변수 안내·502 재시도 안내·성공 배너 단독 노출).

18-3 장르·무드 옵션 (/settings/music-options, SPEC #132)

#128 스캐폴딩한 ComingSoon 을 내실화. 라이브러리·플레이리스트 분류에 쓰이는 장르(GENRE)· 무드(MOOD) 태그 옵션의 CRUD 화면. OPERATOR 전용(backend 도 인가).

시안 출처: 전용 시안 부재 — /settings/policies(#023)의 2섹션 카드 idiom + /settings/profile (#130)의 atom-grounded(명시적 fetch+refetch·낙관적 갱신 없음·inline 에러) 스타일을 미러로 합성. 시안 도착 시 정합 교체([[feedback_design_only_from_handoff]]).

  • 2 섹션 — 장르·무드를 각각 카드 섹션으로 분리. 각 섹션은 generated useListMusicTagOptions({type}) (react-query client-query, mutator apiFetch 가 BFF catch-all /api/backend/... 경유·토큰 서버 전용)로 옵션 목록을 fetch. backend 가 sort_order ASC → value ASC 로 정렬해 내려주므로 FE 재정렬 없음. 비활성(active=false) 옵션도 노출하되 흐림(opacity) + “비활성” 배지로 구분.
  • 입력/출력
    • 추가 — 섹션 하단 인라인 폼(value 필수 · sortOrder 선택). useCreateMusicTagOptionPOST 201. 미입력 시 sortOrder omit(backend 기본 0). 성공 시 해당 타입 query invalidate(refetch).
    • 수정 — 행 [수정] → 인라인 편집 행(value input · sortOrder number input · active Switch). useUpdateMusicTagOptionPATCH 200. 변경된 필드만 전송(미변경 필드는 undefined → 직렬화 제외 = 부분 PATCH). 성공 시 invalidate.
    • 삭제 — 행 [삭제] → 확인 단계(soft delete = active:false, 멱등) → useDeleteMusicTagOptionDELETE 204. 기존 음원 참조 보존(안전). 성공 시 invalidate.
  • 검증/에러 분기value 는 backend OpenAPI(@minLength 1·@maxLength 50)와 동일한 zod 로 사전 검증([[feedback]] frontend.md §8): 빈값·51자 이상은 제출 차단 + inline 에러. sortOrder 는 0 이상 정수만 허용(음수·비숫자 inline 에러). 서버 에러는 mutator ApiError(status·body)에서 extractCode 로 backend ErrorResponse code 를 뽑아 매핑: 409 MUSIC_TAG_OPTION_DUPLICATE(중복 value) · 404 MUSIC_TAG_OPTION_NOT_FOUND(부재) · 권한/5xx/네트워크 공통 메시지. 모두 inline 노출.
  • 빈 상태 CTA — 옵션 0건이면 점선 박스 빈상태(첫 옵션 추가 유도) + 추가 폼은 빈/에러와 무관하게 항상 노출.

구조: page.tsx(server, client 마운트만) → music-options-client.tsx("use client", 2 섹션 × useListMusicTagOptions + create/update/delete mutation). 보호 라우트 가드는 상위 (protected)/layout me 가드. FE-only 화면(BE 계약은 #132 BE 슬라이스) · 신규 env var 0.

테스트: music-options-client.test.tsx(목록 렌더·GENRE/MOOD 분리·비활성 배지·빈 상태 CTA·추가 후 refetch·중복 409 inline 에러·빈값 검증 차단·수정 부분 PATCH·삭제 soft·목록 5xx 에러 배너).

범위 밖: 음원 업로드 폼이 이 옵션을 소비(드롭다운 연동)하는 것은 후속 — 이번은 옵션 관리 CRUD 까지.

18-5 신탁 재생 로그 (/settings/trust-playback-logs, SPEC #176)

#128 스캐폴딩한 ComingSoon 을 내실화(SPEC #176 · #172 FU-3). 운영사(OPERATOR)가 플랫폼 전체 매장·본사의 재생 로그(play_log, 곡당 1행)를 조회하고 서버 CSV 로 내보낸다. 격리 없음(운영사 전권) — 신탁(TRUST) 음원 KOMCA 신고 근거 + 전량 재생 이력 열람.

시안 출처: 전용 시안 부재 — 본사 감사(#067)·운영사 목록 필터/페이지네이션 idiom 미러 (atom-grounded). 시안 도착 시 정합 교체.

  • 필터: 기간(from·to·필수·기본 최근 30일·366일 상한 클라 사전검증) + 신탁 토글(기본 신탁만·해제 시 전량) + 본사 select(useAdminListHqs) + 매장 select(useAdminListStores — 선택한 본사 산하로 좁힌 cascading, 본사 미선택 시 비활성). 플랫폼 전체 매장 select 는 규모상 부적합 → 본사 먼저 고르는 cascading 으로 매장 후보를 bound. 컨트롤 변경 시 page 0 리셋(즉시 적용).
  • 목록: 재생 시각(KST)·본사명·매장명·곡명·음원 구분(AI/TRUST 배지)·재생 시간(m:ss). 정렬 BE 고정(started_at desc → id desc). 로딩·빈 상태·out-of-range page-empty·에러(401/403/5xx/네트워크) 분기 + ListPagination(20/page).
  • CSV 내보내기: 현재 필터 매칭 전체 행(상한 50,000) 을 서버 CSV 로. BFF catch-all /api/backend/api/v1/admin/playback-logs/export 직접 fetch(downloadCsvFromBackend — 운영자 audit CSV #048·본사 audit CSV #070 관례) → blob <a download>. 파일명 서버 Content-Disposition (trust-playback-logs-{yyyyMMdd-HHmmss}.csv KST). 상한 초과 시 X-Export-Truncated: true → 잘림 경고 배너. trustOnly 는 항상 명시(false=전량도 서버에 전달).
  • 계약: listAdminPlaybackLogs·exportAdminPlaybackLogs (endpoints) · DTO PlaybackLogItemDto / PlaybackLogListResponse. 본사 대응 화면은 HQ Mode 신탁 재생 로그(hqId 격리·본사 필터 미노출).

테스트: trust-playback-logs-client.test.tsx(행 렌더·source 배지·KST·신탁 토글 기본 true→false· 기간 경계 정규화·범위 상한 배너·빈 상태·페이지네이션·본사→매장 cascading·403/5xx 매핑·CSV BFF 다운로드 트리거·잘림 경고) + playback-log-format.test.ts(m:ss·경계·range·파일명·라벨).

M1 KOMCA 공식 신고 포맷 — 외부 규격 게이트(권리자 필드·곡별 재생횟수 집계 규격 확정 후 별도). 이번은 raw play 이벤트 CSV 까지.

이메일 템플릿 (/settings/email-templates, SPEC #146)

계정·인증 트랜잭션 이메일 4종(계정 설정 초대·비밀번호 재설정·이메일 변경 인증·이메일 변경 알림)은 코드 소유(EmailTemplates). 운영사가 발송 전에 “어떻게 보이나” 확인(미리보기)하고 “잘 도착하나” 테스트(테스트 발송)하는 도구 화면 — 수준 A(미리보기 + 테스트 발송만, 편집 없음·read-only). 미리보기/발송이 실제 EmailTemplates 를 그대로 재사용해 드리프트 0.

시안 출처: 전용 시안 부재 — handoff 요청서(design_handoff_linkmusic/briefs/requests/ ops-email-templates.md) + ops-shared atom(PageHeader·Banner·Button·Input)으로 atom-grounded 합성. 시안 도착 시 정합 교체([[feedback_design_only_from_handoff]]).

  • 2-pane 레이아웃 — 좌측 4종 목록(useListEmailTemplatesEmailTemplateListItem[], type·label·subject). 선택 시 우측 미리보기 패널 갱신. 첫 종류를 기본 선택.
  • 미리보기 패널usePreviewEmailTemplate(type){ subject, html }. 상단에 subject + 라이트/다크 토글, 본문은 iframe srcDoc={html} 로 렌더(이메일 HTML 의 테이블·인라인 CSS 를 페이지 스타일과 격리). html 은 단일 문서(BE 다크 분기 없음).
    • 다크 토글: 템플릿이 .force-dark 를 지원(.force-dark .em-bg { … })하므로 same-origin srcDoc iframe 의 contentDocument.documentElementforce-dark 클래스를 토글한다 (<html> 에 붙어야 .force-dark .em-bg 매칭). 콘텐츠 교체(새 html)·토글(isDark)·onLoad 모두에서 동기화. 앱 테마와 독립한 미리보기 전용 state. iframe sandbox="allow-same-origin" (스크립트 비활성 — srcDoc 은 BE 렌더 신뢰 콘텐츠).
  • 테스트 메일 보내기 — 대상 입력(기본값 = OPERATOR me.email, useMe() 재사용 → BE me 신설 0) → useTestSendEmailTemplate({type, data:{to}})EmailTemplateTestSendResponse{delivered, emailConfigured} 분기:
    • emailConfigured=false“메일 발송이 설정되지 않았습니다(Azure env 필요) — 실제로 발송되지 않았습니다” 안내 배너(실패가 아니라 미설정 — 실제 미발송 NoOp).
    • delivered=true“테스트 메일을 보냈습니다” 성공 토스트.
    • delivered=false && emailConfigured=true(벤더 실패) → 재시도 안내.
    • 429(rate limit, accountId 단위 5회/분) → “잠시 후 다시 시도” 안내. 404 EMAIL_TEMPLATE_NOT_FOUND 등은 ApiError + extractCode 로 매핑(공용 idiom).
  • 검증 — 대상 email 은 backend OpenAPI(EmailTemplateTestSendRequest.to @maxLength 254)와 동일한 zod(형식 + max 254)로 사전 검증([[feedback]] frontend.md §8).

구조: page.tsx(server, client 마운트만) → email-templates-client.tsx(목록 + 선택 state) → email-template-preview-panel.tsx(iframe + 다크 토글) → email-template-test-send-form.tsx(테스트 발송 폼). 응답 타입은 generated 스키마(EmailTemplateListItem·EmailTemplateListItemType· EmailTemplatePreviewResponse·EmailTemplateTestSend*) 단일 소스(수기 재정의 0). 신규 env 0 (메일 인프라 Azure env 는 BE 소유 — FE 는 emailConfigured 만 분기). 신규 BFF route·middleware allowlist 변경 0(인증된 OPERATOR 호출이 기존 catch-all /api/backend/[...path] 로 프록시).

테스트: email-templates-client.test.tsx(4종 목록 label/subject·기본 선택 미리보기 iframe srcDoc·다크 토글 aria-pressed·테스트 대상 기본 me.email·emailConfigured=false 미설정 안내· delivered=true 성공·429 재시도·종류 전환 시 패널 갱신).

사이드바 진입점

OpsSidebar 하단 “설정” 항목 href 가 /settings/policies/settings 로 변경(SPEC #128 D5). isActive/settings/* 하위 모든 탭(약관·시스템 정책 포함)을 active 매칭하므로 어느 탭에 있든 “설정” 항목이 활성으로 표시된다.

테스트

  • settings-tabs.test.tsx — 7탭 번호순 렌더·각 href·active aria-current·약관 탭 편입·sub-route startsWith 매칭.
  • sidebar.test.tsx — “설정” href /settings·/settings/profile·/settings/policies 에서 active.
  • 기존 약관(policies-client.test·policy-publish-dialog.test·policy-history-dialog.test)은 탭 셸 편입 후에도 불변(회귀 0).