FeaturesHQ (본사)HQ Mode 매장 상세 조회 (apps/space /admin/stores/[id])

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" 이고 폐점 매장이 아닐 때. 회수 술어는 backend hasManagerAccount(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 useIssueHqStoreManagerPOST /api/v1/hq/stores/{storeId}/managers. 성공 201 시 매장 상세를 invalidate(getGetHqStoreQueryKey(id))해 점장 정보 섹션이 갱신되고, 다이얼로그는 닫지 않고 결과 단계로 전환한다.
  • 결과 단계: setupEmailSent !== false → success(“발급한 이메일 주소로 안내 메일 발송 · 그 이메일이 로그인 ID”). setupEmailSent === false → danger — 계정은 있으나 임시 비밀번호 미노출로 진입 경로가 없다. 재발송은 OPERATOR-only 라 “운영사에 재발송 요청” 을 안내한다.
  • 에러 매핑: 409 DUPLICATE_EMAIL이메일 필드 에러(Banner 아님) · 403 PRINCIPAL_SCOPE_MISMATCH(타 본사·미존재 은닉)·AUTH_HQ_SUSPENDED → 권한/정지 안내 · 429 RATE_LIMITED(STORE_PROVISION 20/분) → 재시도 안내 · 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). server notFound() 가 아니라 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).

섹션endpointpayload의미
CM 사이클 빈도 override (#103)PATCH /api/v1/hq/stores/{id}/commercial-cycle (useUpdateStoreCommercialCycle){ commercialCycleSongs: number | null } (null=override 제거)N곡마다 본사 CM송 1회 — 매장 단위 빈도
더킹 overridePATCH /api/v1/hq/stores/{id}/ducking (useUpdateStoreDucking)UpdateStoreDuckingRequest{ duckEnabled, duckVolumePercent, duckFadeMs } — 각 필드 null=override 제거멘트 중 배경음악 감쇠(사용·볼륨%·fade ms) — 매장 단위
  • 더킹 override 구조: 라디오 “본사 기본값 사용(상속)” / “이 매장만 다르게(override)”. override 모드면 사용 토글 + 볼륨%(0100) + fade ms(05000) 노출. 현재 override / HQ default 참조는 HqStoreDetailResponseduck*(매장 override, null=상속) / hqDuck*(본사 default 참조용)에서.
  • ⚠️ 더킹 PATCH 는 전체 replace(부분 PATCH 아님): “상속” 모드 = 세 필드 모두 null(override 전체 제거), “override” 모드 = 세 필드 모두 값. 한 필드만 바꿔도 세 값을 함께 전송한다.
  • 검증(frontend.md §8): duckVolumePercent 0100, duckFadeMs 05000. 범위 밖/변경 없음 → [저장] disabled.
  • 저장 성공 시 success 토스트 “저장되었습니다.”(SPEC #163 — 인라인 배너 대신 공용 ToastHost).
  • testid: hq-store-ducking-section·-mode-default·-mode-override·-toggle·-volume-input· -fade-input·-submit·-error·-effective(성공 확인은 토스트로 이전 — -success testid 제거).

매장 지역 편집 (SPEC #144)

CM 사이클·더킹 섹션과 같은 위계로 매장 지역(시/도) 섹션이 있다 — REGION 모드 송출의 그룹핑 축. 본사가 매장에 지역을 태그하면 송출/반복예약 다이얼로그에서 target=REGION 으로 같은 시/도 매장군에 일괄 송출할 수 있다(미지정 매장은 지역 송출에 포함 X).

섹션endpointpayload의미
매장 지역 (#144)PATCH /api/v1/hq/stores/{id}/region (useUpdateStoreRegion)UpdateStoreRegionRequest{ region: Region | null } (null=미지정 clear)매장 시/도 태그 — REGION 송출 그룹핑
  • 구조: 시/도 select(Region 17종 + 맨 위 “미지정” 옵션) + [저장]. HqStoreDetailResponse.region 으로 현재값 prefill, 변경 0 시 [저장] disabled(no-op 차단). “미지정” 선택 = null clear.
  • 한글 라벨은 FE 단일 소스 apps/space/src/lib/region.ts(REGION_LABELS·REGION_OPTIONS·regionLabel) — BE Region.displayName 은 OpenAPI 에 enum name 만 노출되므로 FE 가 한글 맵을 보유.
  • 저장 성공 시 매장 상세 invalidate + success 토스트 “저장되었습니다.”(SPEC #163 — 인라인 배너 대신 공용 ToastHost). audit HQ_STORE_REGION_UPDATED.
  • testid: hq-store-region-section·-select·-submit·-error·-effective(성공 확인은 토스트로 이전 — -success testid 제거).

매장 정보 인라인 편집 (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 비-blanknull 불가(명시 null 시 BE 400)
주소text≤200빈 입력 → null clear
담당자 이름text≤50빈 입력 → null clear
담당자 이메일email@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=alert Field error) 노출 + [저장] disabled.
  • 변경 0 또는 검증 실패 시 [저장] disabled(no-op 호출 차단).
  • backend 가 최종 권위 — HQ_STORE_INVALID_FIELD 400 도 에러 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 사후 발급 + audit HQ_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.kt getHqStore·updateHqStore · api/hq/HqStoreDtos.kt HqStoreDetailResponse·UpdateHqStoreRequest · application/hq/HqStoreQueryService.kt updateStore(변경 필드 collect + audit detail) · domain/enums/HqAuditAction.kt HQ_STORE_UPDATED · domain/enums/HqAuditTargetType.kt STORE.
  • 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).