운영사 설정 — /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.tsx—next/link기반 라우트 탭. 활성 표시는 링크 표준aria-current="page"+ 좌측 primary 도트(audit 탭 시각 미러).isActive는 정확 일치 + “href + 슬래시” 접두 매칭으로 sub-route 도 활성 처리. 탭 순서는 PRD 번호순(1→7).(protected)/settings/page.tsx—redirect("/settings/profile")(번호순 첫 탭, SPEC #128 D2).
7서브 (PRD Page 18 · 번호순 탭)
| # | 탭 | slug | 상태 |
|---|---|---|---|
| 18-1 | 내 프로필 | /settings/profile | 내실 완료(SPEC #130) — 본인 정보(이름·이메일·역할 read) + 비밀번호 변경. 상세 |
| 18-2 | 알림 설정 | /settings/notifications | TTS 연결 확인(SPEC #134) — 안내방송(TTS) smoke-test 버튼. 이메일·시스템 알림 토글은 추후. 상세 |
| 18-3 | 장르·무드 옵션 | /settings/music-options | 내실 완료(SPEC #132) — 장르(GENRE)·무드(MOOD) 태그 옵션 CRUD(추가·수정·삭제=soft). 상세 |
| 18-4 | 시스템 정책 | /settings/system-policies | ComingSoon — 18-7 /settings/policies 충돌 회피 분리 slug(SPEC #128 D4) |
| 18-5 | 신탁 재생 로그 | /settings/trust-playback-logs | 내실 완료(SPEC #176 · #172 FU-3) — 플랫폼 전체 재생 로그 조회(기간·본사·매장 cascading·신탁 토글) + 서버 CSV 내보내기. 상세 |
| 18-6 | LLM 자동멘트 | /settings/llm-ments | ComingSoon — 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 +ChangePasswordSectionidiom 을 정확 미러로 atom-grounded 합성. 시안 도착 시 정합 교체 ([[feedback_design_only_from_handoff]]).
- 본인 정보(read-only) —
useMe()(/api/auth/meBFF → 단일 소스MeResponse) 응답을 DescCard 3행으로 노출: 이름(name,@nullable→ null 이면—)·이메일(email)·역할 (role→OPERATOR“운영자” 등 한글 라벨). 운영자 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 OpenAPIChangePasswordRequest8~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): 503TTS_TOKEN_NOT_CONFIGURED→ “TTS 토큰이 설정되지 않았습니다. Render 환경변수 TYPECAST_API_TOKEN 을 확인해주세요.” · 502TTS_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, mutatorapiFetch가 BFF catch-all/api/backend/...경유·토큰 서버 전용)로 옵션 목록을 fetch. backend 가sort_order ASC → value ASC로 정렬해 내려주므로 FE 재정렬 없음. 비활성(active=false) 옵션도 노출하되 흐림(opacity) + “비활성” 배지로 구분. - 입력/출력
- 추가 — 섹션 하단 인라인 폼(
value필수 ·sortOrder선택).useCreateMusicTagOption→POST201. 미입력 시sortOrderomit(backend 기본 0). 성공 시 해당 타입 query invalidate(refetch). - 수정 — 행 [수정] → 인라인 편집 행(
valueinput ·sortOrdernumber input ·activeSwitch).useUpdateMusicTagOption→PATCH200. 변경된 필드만 전송(미변경 필드는undefined→ 직렬화 제외 = 부분 PATCH). 성공 시 invalidate. - 삭제 — 행 [삭제] → 확인 단계(soft delete =
active:false, 멱등) →useDeleteMusicTagOption→DELETE204. 기존 음원 참조 보존(안전). 성공 시 invalidate.
- 추가 — 섹션 하단 인라인 폼(
- 검증/에러 분기 —
value는 backend OpenAPI(@minLength 1·@maxLength 50)와 동일한 zod 로 사전 검증([[feedback]] frontend.md §8): 빈값·51자 이상은 제출 차단 + inline 에러.sortOrder는 0 이상 정수만 허용(음수·비숫자 inline 에러). 서버 에러는 mutatorApiError(status·body)에서extractCode로 backendErrorResponsecode 를 뽑아 매핑: 409MUSIC_TAG_OPTION_DUPLICATE(중복 value) · 404MUSIC_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}.csvKST). 상한 초과 시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종 목록(
useListEmailTemplates→EmailTemplateListItem[], type·label·subject). 선택 시 우측 미리보기 패널 갱신. 첫 종류를 기본 선택. - 미리보기 패널 —
usePreviewEmailTemplate(type)→{ subject, html }. 상단에 subject + 라이트/다크 토글, 본문은 iframesrcDoc={html}로 렌더(이메일 HTML 의 테이블·인라인 CSS 를 페이지 스타일과 격리). html 은 단일 문서(BE 다크 분기 없음).- 다크 토글: 템플릿이
.force-dark를 지원(.force-dark .em-bg { … })하므로 same-originsrcDociframe 의contentDocument.documentElement에force-dark클래스를 토글한다 (<html>에 붙어야.force-dark .em-bg매칭). 콘텐츠 교체(새 html)·토글(isDark)·onLoad 모두에서 동기화. 앱 테마와 독립한 미리보기 전용 state. iframesandbox="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·activearia-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).