HQ Mode — 본사 매장 상세 (apps/space /admin/stores/[id])
SPEC #084 도입(read 슬라이스) → SPEC #103 CM 사이클 빈도 override 편집 → SPEC #105 매장 정보 인라인 편집(#084 F1 마감) → SPEC #116 detail 에 managerName/managerEmail 노출(#084 F6 마감 — 개요 표시 + 편집 폼 prefill). 실 HQ_MANAGER 직접 로그인 표면(apps/space · space.linkmusic.io) — 운영사 임퍼소네이션(apps/admin)이 아니다.
이 페이지는
apps/space의 실 HQ_MANAGER 본사 모드다. 운영사(OPERATOR)가 매장을 관리하는apps/admin/stores/[id](SPEC #036 4탭 — 정지/복구/폐점·점장 발급/관리)와 별개다. 본사 모드는 매장 마스터 정보(이름·주소·매니저 연락처) 와 재생 동작 override (CM 사이클 빈도·더킹) 만 직접 편집 가능하다(SPEC #105·#103 + 더킹). 정지/복구/폐점·점장 발급/관리는 여전히 운영자(OPERATOR) 영역(apps/admin).#170 — 활성 PL 모델. 운영사가 매장별 활성 PL 을 지정하던 것은 제거됐다. 운영사는 본사 기본 (디폴트) PL 만 정하고, 매장 활성 PL 은 점장이 player 에서 선택한다 (#129). 본 본사 매장 상세의 “활성 플레이리스트” 섹션은 점장이 고른 값을 읽기전용으로만 표시한다.
Overview
HQ_MANAGER 가 apps/space 본사 모드에서 자기 본사 산하 매장 1건의 상세를 조회한다(read-only).
매장 기본 정보·운영 상태·점장 계정 정보·활성 플레이리스트·생성/수정 시각을 5 섹션에서 본다.
전부 hqId 스코프(본인 본사 산하 매장만 — 토큰 주체에서 도출, BE verifyHqScope. 타 본사 매장
ID 호출 → 404 STORE_NOT_FOUND 으로 존재 은닉, SPEC #084 §D2).
시안 출처: workspace parent dir — 본사 매장 상세 전용 시안 부재로 운영사 매장 상세 (
apps/admin/stores/[id]SPEC #036) 5 섹션 패턴 + 본사 PL 상세(/admin/playlists/[id]#057) 헤더 스타일 일관(본사 모드 관용구, 임의 디자인 금지).
진입점
본사 매장 목록(/admin/stores, SPEC #051) 행의 매장명 셀 을 클릭하면 본 상세 페이지로
네비게이션한다(hq-store-link-{storeId} Link). 운영사 매장 목록의 매장명 셀 → 상세(#036) 패턴과
동일(단, 본사는 OrgAvatar·plan 컬럼이 없는 read-only 목록 — SPEC #051 §FE).
5 섹션
apps/space/src/app/admin/stores/[id]/page.tsx(server 셸) + hq-store-detail-client.tsx(client).
본사 매장 목록(#051)이 client-query 패턴(서버사이드 페이지네이션·필터)이라 일관 유지 — id 도
path 파라미터라 server 사전 fetch 의 가치가 없다(보조 데이터 없음·mutating 진입점 없음). client
가 useGetHqStore(id)(catch-all BFF proxy 경유)로 직접 조회한다.
| 섹션 | 데이터 | 빈 상태 |
|---|---|---|
| 개요 | name·address·managerName·managerEmail·phone(담당자 전화)·유형 배지·상태 배지·폐점 시각(있을 때) | 주소·담당자·전화 null → — |
| 점장 정보 | storeManager.{email,name,status} + [점장 발급] 진입점(SPEC #184 D4) | storeManager == null → “점장 계정 미발급” 점선 박스 + [점장 발급] |
| 활성 플레이리스트 (읽기전용) | activePlaylist.{id,name} — 점장이 player 에서 고른 값(#129) | activePlaylist == null → “활성 플레이리스트 없음 — 본사 기본 PL fallback 가능” 점선 박스(SPEC #058) |
| 운영 상태 | 상태 배지 재노출 + 정지/폐점 시 danger 안내 | — |
| 메타 정보 | 생성/수정 시각(KST formatKstDateTime) | — |
헤더는 본사 PL 상세 미러(뒤로가기 → /admin/stores, 제목 매장명 + 상태/유형/폐점 배지 칩).
점장 계정 사후 발급 (SPEC #184 D4)
본사가 자기 산하 매장에 점장 계정을 직접 발급할 수 있게 됐다(새로 연 권한). 생성 시점을 놓친 매장의 유일한 복구 경로다 — “점장 미정” 등록(D2) · 이메일 중복으로 계정 없이 재등록(D9) · 점장 회수(WITHDRAWN) 후 재발급(D8 / 감사 AC-F1). 이 경로가 없으면 본사가 만든 반쪽 매장은 운영사 문의밖에 길이 없다.
- 노출 조건:
storeManager == null또는storeManager.status === "WITHDRAWN"이고 폐점 매장이 아닐 때. 회수 술어는 backendhasManagerAccount(status <> WITHDRAWN)와 같은 기준이라 목록의 [점장 계정: 미발급] 필터 결과와 화면이 어긋나지 않는다. 회수 상태에서는 보조 문구(hq-store-manager-withdrawn-note)로 “로그인 가능한 점장이 없다” 를 명시한다. - 다이얼로그(
hq-store-manager-issue-dialog.tsx): 이메일(필수 · 로그인 ID)·이름(선택) 2필드. 임시 비밀번호 입력란이 없다(D6 — 서버 생성 · 미노출, 계정 설정 메일이 유일한 진입 경로). 매장 연락 컬럼(managerEmail·managerName)을 프리필한다(감사 A-0 ① — 같은 값 재타이핑 차단). 전송 중에는 Esc·바깥 탭·[취소]가 모두 잠긴다(frontend.md §18). - 호출: generated
useIssueHqStoreManager→POST /api/v1/hq/stores/{storeId}/managers. 성공 201 시 매장 상세를 invalidate(getGetHqStoreQueryKey(id))해 점장 정보 섹션이 갱신되고, 다이얼로그는 닫지 않고 결과 단계로 전환한다. - 결과 단계:
setupEmailSent !== false→ success(“발급한 이메일 주소로 안내 메일 발송 · 그 이메일이 로그인 ID”).setupEmailSent === false→ danger — 계정은 있으나 임시 비밀번호 미노출로 진입 경로가 없다. 재발송은 OPERATOR-only 라 “운영사에 재발송 요청” 을 안내한다. - 에러 매핑: 409
DUPLICATE_EMAIL→ 이메일 필드 에러(Banner 아님) · 403PRINCIPAL_SCOPE_MISMATCH(타 본사·미존재 은닉)·AUTH_HQ_SUSPENDED→ 권한/정지 안내 · 429RATE_LIMITED(STORE_PROVISION20/분) → 재시도 안내 · 400/5xx → 일반 안내. - a11y: Banner 기본 role 은 status(polite) 이므로 사고성 danger 배너(발급 실패 ·
“이 계정은 지금 로그인할 수 없습니다”)에는
role="alert"를 명시한다 — 운영사 마법사와 동일 규칙. - audit: 발급마다
HqAuditAction.HQ_STORE_MANAGER_ISSUED+HqAuditTargetType.STORE(D5 — 새로 여는 권한은 처음부터 기록되게 한다). 본사·운영사 감사 뷰 라벨 “매장 점장 계정 발급”(info). - 시안: 전용 시안 부재 → atom-grounded(#106 폼 idiom + 본사 다이얼로그 골격). design-debt 등재 대상.
상태 분기
- 로딩 — 스켈레톤 박스 3 줄(
hq-store-detail-loading). - 에러 분기는 공용
ErrorState(SPEC #154) — notFound 는 [매장 목록으로] 복귀 링크(actionHref), 그 외 일시 장애는 다시 시도. - 404
STORE_NOT_FOUND(타 본사 매장·미존재 모두 은닉) — “매장을 찾을 수 없습니다.” + [매장 목록으로] (hq-store-detail-not-found). servernotFound()가 아니라 client 분기 — useGetHqStore 가 ApiError 로 보고하므로mapDetailError가 status===404 || code===STORE_NOT_FOUND흡수. - 403
PRINCIPAL_SCOPE_MISMATCH— “이 페이지를 볼 권한이 없습니다.” + [다시 시도]. - 5xx · 네트워크 — “서버에 일시적인 문제…” 또는 “서버에 연결할 수 없습니다.” + [다시 시도] (
hq-store-detail-error).
인가
/api/v1/hq/stores/{id} → hasRole("HQ_MANAGER") 1차 경계 + service PrincipalScopeGuard
claim↔DB 재검증(SPEC #049). 타 본사 매장은 404 으로 존재 은닉(403 으로 노출 시 매장 ID 유효성
정보 누설). 미인증 401 · 비활성/role·소속 불일치 403 PRINCIPAL_SCOPE_MISMATCH. 클라이언트 fetch
는 generated apiFetch 가 BFF catch-all /api/backend/... 경유(토큰 서버 전용).
재생 동작 설정 — CM 사이클 / 더킹 override
5 섹션 아래에 매장별 재생 동작 override 섹션 2종이 있다. 둘 다 본사 default(/admin/settings)를
매장 단위로 덮어쓰는 같은 위계 — 라디오(본사 default 사용 / 이 매장만 다르게) + 입력 + [저장].
저장 성공 시 매장 상세 invalidate(effective 가 점장 player me 로 read-back).
| 섹션 | endpoint | payload | 의미 |
|---|---|---|---|
| CM 사이클 빈도 override (#103) | PATCH /api/v1/hq/stores/{id}/commercial-cycle (useUpdateStoreCommercialCycle) | { commercialCycleSongs: number | null } (null=override 제거) | N곡마다 본사 CM송 1회 — 매장 단위 빈도 |
| 더킹 override | PATCH /api/v1/hq/stores/{id}/ducking (useUpdateStoreDucking) | UpdateStoreDuckingRequest{ duckEnabled, duckVolumePercent, duckFadeMs } — 각 필드 null=override 제거 | 멘트 중 배경음악 감쇠(사용·볼륨%·fade ms) — 매장 단위 |
- 더킹 override 구조: 라디오 “본사 기본값 사용(상속)” / “이 매장만 다르게(override)”. override 모드면
사용 토글 + 볼륨%(0
100) + fade ms(05000) 노출. 현재 override / HQ default 참조는HqStoreDetailResponse의duck*(매장 override, null=상속) /hqDuck*(본사 default 참조용)에서. - ⚠️ 더킹 PATCH 는 전체 replace(부분 PATCH 아님): “상속” 모드 = 세 필드 모두 null(override 전체 제거), “override” 모드 = 세 필드 모두 값. 한 필드만 바꿔도 세 값을 함께 전송한다.
- 검증(frontend.md §8):
duckVolumePercent0100,5000. 범위 밖/변경 없음 → [저장] disabled.duckFadeMs0 - 저장 성공 시 success 토스트 “저장되었습니다.”(SPEC #163 — 인라인 배너 대신 공용 ToastHost).
- testid:
hq-store-ducking-section·-mode-default·-mode-override·-toggle·-volume-input·-fade-input·-submit·-error·-effective(성공 확인은 토스트로 이전 —-successtestid 제거).
매장 지역 편집 (SPEC #144)
CM 사이클·더킹 섹션과 같은 위계로 매장 지역(시/도) 섹션이 있다 — REGION 모드 송출의 그룹핑 축.
본사가 매장에 지역을 태그하면 송출/반복예약 다이얼로그에서 target=REGION 으로 같은 시/도 매장군에
일괄 송출할 수 있다(미지정 매장은 지역 송출에 포함 X).
| 섹션 | endpoint | payload | 의미 |
|---|---|---|---|
| 매장 지역 (#144) | PATCH /api/v1/hq/stores/{id}/region (useUpdateStoreRegion) | UpdateStoreRegionRequest{ region: Region | null } (null=미지정 clear) | 매장 시/도 태그 — REGION 송출 그룹핑 |
- 구조: 시/도 select(
Region17종 + 맨 위 “미지정” 옵션) + [저장].HqStoreDetailResponse.region으로 현재값 prefill, 변경 0 시 [저장] disabled(no-op 차단). “미지정” 선택 = null clear. - 한글 라벨은 FE 단일 소스
apps/space/src/lib/region.ts(REGION_LABELS·REGION_OPTIONS·regionLabel) — BERegion.displayName은 OpenAPI 에 enum name 만 노출되므로 FE 가 한글 맵을 보유. - 저장 성공 시 매장 상세 invalidate + success 토스트 “저장되었습니다.”(SPEC #163 — 인라인 배너 대신 공용
ToastHost). audit
HQ_STORE_REGION_UPDATED. - testid:
hq-store-region-section·-select·-submit·-error·-effective(성공 확인은 토스트로 이전 —-successtestid 제거).
매장 정보 인라인 편집 (SPEC #105 — #084 F1 마감)
매장 정보(개요) 섹션 헤더 우측 [편집] 버튼 → 같은 페이지에서 read ↔ edit 모드 토글한다(D5
inline · 별도 /edit 라우트 추가 X — 시안 부재라 atom-grounded). edit 모드에서는 DescCard 가
<HqStoreEditForm> 인라인 폼으로 교체된다(apps/space/src/app/admin/stores/[id]/ hq-store-edit-form.tsx).
| 필드 | 입력 | 제약(BE OpenAPI 미러, D8) | clear 의미(D2) |
|---|---|---|---|
| 매장명 | text 필수 | 1..50 비-blank | null 불가(명시 null 시 BE 400) |
| 주소 | text | ≤200 | 빈 입력 → null clear |
| 담당자 이름 | text | ≤50 | 빈 입력 → null clear |
| 담당자 이메일 | @Email + ≤255 | 빈 입력 → null clear | |
| 담당자 전화번호 | tel | ≤30 free-form | 빈 입력 → null clear |
PATCH 의미(D2) — 변경된 필드만 payload 에 담는다(키 부재=미변경). 빈 입력은 명시 null 로
전송해 BE 가 컬럼을 비운다. orval 이 JsonNullable partial 의미를 OpenAPI 로 표현하지 못해
generated 타입은 5 필드 모두 required 로 떨어지는데, FE 는 Partial<UpdateHqStoreRequest> 를
generated client 에 cast 해 우회한다(runtime 으로만 정합 — apps/space/src/lib/backend.ts
BackendUpdateHqStoreRequest 주석 참조).
초기값 prefill(SPEC #116 — #084 F6 마감) — HqStoreDetailResponse 가 store 연락 컬럼
managerName/managerEmail 을 read 응답에 노출하므로(phone=store.manager_phone 은 기존),
편집 폼 5필드(name·address·managerName·managerEmail·managerPhone)가 detail 의 현재값을 그대로
prefill 한다(toFormState). null/미설정 → 빈 문자열. 변경 추적이 trim 비교라 사용자가 손대지
않은 필드는 미변경(키 부재) 으로 BE 가 기존 값 보존, 빈 입력으로 비우면 D2 의 null clear 로 전송.
이 store 연락 컬럼은 점장(STORE_MANAGER) 계정(storeManager)과 별개 — 혼동 금지.
검증 UX
- 입력 단계에서 길이/형식 검증 → 위반 시 helper(
role=alertField error) 노출 + [저장] disabled. - 변경 0 또는 검증 실패 시 [저장] disabled(no-op 호출 차단).
- backend 가 최종 권위 —
HQ_STORE_INVALID_FIELD400 도 에러 Banner 로 매핑.
에러 매핑 (hq-store-edit-form.tsx mapSaveError):
- 401/
AUTH_UNAUTHENTICATED→ “다시 로그인해주세요.” - 403/
PRINCIPAL_SCOPE_MISMATCH→ “이 매장을 편집할 권한이 없습니다.” - 404/
STORE_NOT_FOUND→ “매장을 찾을 수 없습니다.” (타 본사 매장 BE 가 404 로 은닉) - 400/
HQ_STORE_INVALID_FIELD→ “입력값을 확인해 주세요.” - 5xx → “서버에 일시적인 문제가 있습니다. 잠시 후 다시 시도해주세요.”
BACKEND_UNREACHABLE→ “서버에 연결할 수 없습니다.”
저장 성공 흐름 — 부모(HqStoreDetailClient)가 query invalidate
(getGetHqStoreQueryKey(id)) + read 모드 복귀 + 1 회성 success 토스트(“매장 정보가 저장
되었습니다.”, SPEC #163 — 인라인 배너 대신 공용 ToastHost. form 이 unmount 돼도 토스트는 셸
viewport 에 유지되므로 부모의 savedAnnouncement 상태가 불필요해짐). read-back response(D7)가
동일 query 키로 캐싱되어 refetch 1회로 즉시 반영된다.
audit — 본사 audit 페이지(/admin/audit)에 HQ_STORE_UPDATED 액션 + STORE target type 으로
1행 노출(변경된 필드만 detail.changedFields + before/after partial 스냅샷). 변경 0 = audit row X.
사이드바
HQSidebar “매장”(/admin/stores) 항목 유지 — 상세 페이지는 그 하위로 들어와도 같은 메뉴
하이라이트(상세 전용 별도 메뉴 항목 없음).
Followups
- F1 매장 편집 — ✅ SPEC #105 마감.
- F2 매장 등록 — 본사 직접 매장 등록(현재는 운영사 onboarding
POST /api/v1/admin/hq/{hqId}/stores전용, SPEC #011).type정책 결정 후. - F3 매장 CSV 일괄 등록 — 대규모 본사 시나리오.
- F4 매장 상태 전이 본사 위임 — 정지/복구/폐점(현재 운영자 전용) 본사 위임 결정 후.
- F5 점장 계정 발급/관리 본사 위임 — ✅ 발급은 SPEC #184 마감(등록 시점 동시 발급 +
POST /api/v1/hq/stores/{storeId}/managers사후 발급 + auditHQ_STORE_MANAGER_ISSUED). 관리(정지·복구·회수·비밀번호 재설정)는 여전히 운영사 전용 — 본사 위임 미결정. - F6 detail response 에
managerName/managerEmail노출 — ✅ SPEC #116 마감(개요 카드 표시 + 편집 폼 prefill 정합). - F7 시안 도착 — 본사 매장 상세 전용 시안 도착 시 5 섹션 시각 재정합.
References
- SPEC
docs/specs/084-hq-store-detail.md(BE getHqStore + FE 5 섹션 + 진입점). - SPEC
docs/specs/105-hq-store-info-edit.md(#084 F1 마감 — 매장 정보 인라인 편집). - SPEC
docs/specs/116-hq-store-detail-manager-fields.md(#084 F6 마감 — detail 에managerName/managerEmail노출 + 편집 폼 prefill). - SPEC
docs/specs/184-store-creation-consolidation.md(#084 F5 발급 마감 — 매장 생성 = 계정 생성 · 본사 사후 발급 권한 · “계정 미발급” 필터). - BE:
api/hq/HqStoreController.ktgetHqStore·updateHqStore·api/hq/HqStoreDtos.ktHqStoreDetailResponse·UpdateHqStoreRequest·application/hq/HqStoreQueryService.ktupdateStore(변경 필드 collect + audit detail) ·domain/enums/HqAuditAction.ktHQ_STORE_UPDATED·domain/enums/HqAuditTargetType.ktSTORE. - FE:
apps/space/src/app/admin/stores/[id]/page.tsx·hq-store-detail-client.tsx·hq-store-edit-form.tsx(#105 인라인 편집 폼) · 진입점apps/space/src/app/admin/stores/hq-store-list-client.tsx(매장명 Link). - 운영사 매장 상세 패턴 미러:
apps/admin/src/app/(protected)/stores/[id]/store-detail-client.tsx(SPEC #036).