Store Detail — /stores/:id (매장 상세)
SPEC #036 정합. HQ 상세(#020)와 대칭 — 매장 단건 상세: 헤더 + 4탭.
시안 정합. 시각 레이아웃은 handoff 시안
design/screens/ops-store-detail.jsx(배치6 ④ — ops-hq-detail 관용구 미러: 헤더 status/type/plan pill + 정지/복구/폐점 액션 + 4탭 + 정지/폐점 상태 배너 + DescCard 2컬럼 + 점장 계정 테이블 + OpsEmpty placeholder) 정합 — 기존@linkmusic/uiatom(PageHeader·Tabs/Tab·DescCard·StatusPill·테이블·Banner·점선 박스 빈상태)에 매핑했다. HQ 상세(ops-hq-detail)와 시각 일관. 기능·계약·상태 분기 불변(시각만).
Overview
운영자(OPERATOR)가 /stores 목록에서 매장명 셀을 클릭해 진입하는 단건 상세. 헤더(매장명 +
status/type/plan pill) + 4탭. 정산(결제)·감사로그(활동) 도메인이 미구축이라 개요·점장 계정
2탭은 실데이터, 결제·활동 2탭은 “준비 중” placeholder. 매장 자체 상태 전이는 비목표(조회 전용).
개요 탭 하단에 #055 “활성 플레이리스트” 섹션(조회/적용/해제)이 추가됐다 — 음악 2계층의 매장 적용 층.
Spec
Header
<PageHeader> — 제목 = 매장명, 부제 = <StatusPill> status(활성/비활성/정지, 정지 시 dot pulse)
- type(독립/직영/가맹) + plan(AI/TRUST) + 소속 본사명·가입일(KST) 메타. 폐점 매장은 status pill
옆에 “폐점됨” danger 배지 병기(#039). 우측 secondary 슬롯 =
status·closedAt 기반 전이/폐점 액션 + [목록으로] ghost 링크(
/stores).
상태 전이 + 폐점 액션 (#037 · #039) — secondary 슬롯에 status·closedAt 기반 버튼:
| 조건 | 버튼 | 동작 |
|---|---|---|
closedAt == null · ACTIVE/INACTIVE | [정지] (danger) + [폐점] (danger) | StoreStatusTransitionDialog suspend·close 모드 |
closedAt == null · SUSPENDED | [복구] (secondary) + [폐점] (danger) | reactivate·close 모드 |
closedAt != null (폐점 매장) | (액션 없음) | 정지/복구/폐점 모두 숨김 — 폐점은 terminal·비가역. 헤더에 “폐점됨” danger 배지 |
- suspend 모드 — 정지 사유(필수, zod trim·max255) + 위험 경고 2-step 확인.
- reactivate 모드 — 사유 없는 단순 2-step 확인.
- close 모드 (#039) — 폐점 사유(필수, zod trim·max255) + “이 매장을 폐점하면 되돌릴 수 없습니다” 비가역 danger 경고 + 2-step 확인. 비가역(terminal) 파괴적 액션.
다이얼로그는 useSuspendStore/useReactivateStore/useCloseStore(BFF 경유) 호출. 409
STORE_INVALID_STATUS_TRANSITION·409 STORE_ALREADY_CLOSED(이미 폐점된 매장입니다)·404
STORE_NOT_FOUND(매장을 찾을 수 없습니다)·403 AUTH_*·5xx 를 인라인 danger 메시지로 매핑.
pending 중 닫기 차단. 성공 시 router.refresh() → 상세(헤더 status 배지·폐점됨 배지·개요) 재검증.
시각은 시안 ops-store-detail §StoreTransitionDialog(ops-hq-detail §SuspendHQDialog 관용구
미러: danger 경고 + Field label/hint + TextArea) 정합 — HQ 상태전이 다이얼로그와 시각 일관.
4탭
| 탭 | 데이터 | 내용 |
|---|---|---|
| 개요 | 실데이터 | DescCard 2컬럼. 좌측 “매장 기본 정보”(소속 본사 · 타입 · 상태 · 요금제 · 주소 · 정산 기준일 · 정지 사유(SUSPENDED 일 때만 danger highlight 행, #037) · 폐점일(closedAt 있을 때만 danger highlight 행) · 생성일 · 수정일). 우측 “점장 연락처·상태”(점장 이름 · 이메일 · 연락처 · 마지막 온라인 · 마지막 하트비트). null 필드는 —. 하단에 “활성 플레이리스트” 섹션(#055, 아래 별도 절). 폐점 매장은 섹션 숨김(terminal). |
| 점장 계정 | 실데이터 | managers 목록 테이블 (이메일 · 이름 · 상태 · 비밀번호(passwordMustChange → “변경 필요” 배지) · 최근 로그인 · 가입일). 카드 헤더 바 우측 [관리](#029 StoreManagerManageDialog) · 빈상태 시 [점장 발급](#021 StoreManagerIssueDialog) CTA. |
| 결제 | placeholder | ”준비 중 — 정산 도메인 후속” 점선 박스 빈상태 (정산 미구축) |
| 활동 | placeholder | ”준비 중 — 감사로그 후속” 점선 박스 빈상태 (매장 감사로그 미구축) |
탭 바는 @linkmusic/ui <Tabs>/<Tab> 재사용. 라우팅 없이 패널만 전환. 점장 계정 탭에 count
배지(managers.length)를 표시한다. 정지/폐점 매장은 헤더 아래에 danger 상태 배너(시안
§Banner)를 노출한다. 시각 레이아웃은 시안 ops-store-detail·HQ 상세와 일관.
점장 탭 액션 통합 (#021 · #029)
발급(#021)·관리(#029) 다이얼로그를 상세 점장 탭에서 진입점으로 재사용한다(다이얼로그 계약
불변 — 상세는 진입점만 제공). 두 다이얼로그는 target: StoreAdminListItem 형태를 요구하므로
StoreDetailResponse 를 그 shape 으로 투영해 넘긴다(id·name 사용). 액션 성공 시 다이얼로그가
내부적으로 router.refresh() 를 호출해 server component(page.tsx)가 상세(managers)를 재검증한다.
활성 플레이리스트 섹션 (#055 — 매장 적용 층)
음악 2계층(음원→라이브러리→플레이리스트)의 매장 적용 층(StoreActivePlaylist). 개요 탭 하단에
카드 섹션으로 노출하며, 운영사가 매장에 본사 플레이리스트 1개를 활성으로 적용/해제/조회한다.
매장당 단일 활성·공유 모델(PL 참조, 복사본 아님). 폐점 매장(closedAt != null)은 섹션을
숨긴다(terminal — 헤더 전이 액션 규칙과 일관).
- 표시:
useGetStoreActivePlaylist(storeId)(client query)로 현재 적용 PL 을 표시.active=true면 PL 이름 + “적용 중” pill + 담긴 라이브러리 수 + 적용 시각(KST) + [변경]/[해제].active=false면 “적용된 플레이리스트 없음” 빈상태 + [적용] CTA. - 적용/변경(
StoreActivePlaylistApplyDialog): 후보 PL select → [적용]. 후보 목록은 매장 소속 본사(hqId)로 한정(useListPlaylists({ hqId }))해 hqId mismatch 를 사전 차단(사고 방지). 적용은useSetStoreActivePlaylist(PUT) — 기존 활성 PL 은 교체된다(단일 활성). - 해제(
StoreActivePlaylistClearDialog): 1-step 확인(가역적 — 재적용 가능) →useClearStoreActivePlaylist(DELETE, 멱등 204). 현재 적용 PL 이름을 확인 문구에 노출. - 활성 PL 부재 시 점장 큐 fallback(#058): 매장에 활성 PL 이 없어도(
active=false) 점장 큐(#056)는 그 매장 본사의 기본 PL(playlist.is_default)로 fallback 해 재생한다(응답source=DEFAULT, 비파괴 —store.active_playlist_id는 변경하지 않음·조회 시점). 본사 기본 PL 도 없으면 빈 큐(NO_ACTIVE_PLAYLIST). 기본 PL 지정은 Playlist 운영사 화면 전용. - mutation 성공 시
getGetStoreActivePlaylistQueryKey(storeId)를 invalidate → 표시 즉시 갱신. - 섹션은 client query/mutation 훅을 캡슐화한 별도 컴포넌트(
store-active-playlist-section.tsx)로 분리 — server-prop 기반store-detail-client테스트가 영향받지 않도록(detail-client 테스트는 섹션 stub).
Implementation
- server:
app/(protected)/stores/[id]/page.tsx—backendGetStoreDetail(accessToken, id)server fetch. refresh-aware(refreshIfNeeded → 401 forceRefresh 1회 재시도 → destroy+redirect), 404STORE_NOT_FOUND→notFound(), 5xx/네트워크 → client banner(errorMessage). HQ 상세page.tsx미러(매장 상세는 보조 fetch 없는 단건). - client:
store-detail-client.tsx— 헤더 + 4탭(use client).BackendStoreDetailResponse·BackendStoreManagerSummaryItem(lib/backend generated alias). - client (#055):
store-active-playlist-section.tsx(개요 탭 활성 PL 섹션) +store-active-playlist-apply-dialog.tsx(적용/변경) +store-active-playlist-clear-dialog.tsx(해제). server-side helperbackendGetStoreActivePlaylist·backendSetStoreActivePlaylist·backendClearStoreActivePlaylist(lib/backend) 단일 소스 제공(화면은 client 훅 사용).
endpoint / DTO
GET /api/v1/admin/stores/{id}—getStoreDetail, OPERATOR-only. →StoreDetailResponse.- 점장 항목 =
StoreManagerSummaryItem(SUSPENDED·WITHDRAWN 포함). - #055 활성 플레이리스트:
getStoreActivePlaylist(GET)·setStoreActivePlaylist(PUT)·clearStoreActivePlaylist(DELETE)/api/v1/admin/stores/{storeId}/active-playlist, OPERATOR-only. →StoreActivePlaylistResponse·SetStoreActivePlaylistRequest.
States & Edge Cases
| 상태 | 처리 |
|---|---|
| 존재하지 않는 id | 404 STORE_NOT_FOUND → notFound() |
| 5xx / 네트워크 실패 | client banner (세션 유지, 강제 로그아웃 X) |
| 401 (refresh 실패) | session.destroy() + /login redirect |
| plan/address/manager*·lastOnlineAt·lastHeartbeatAt null | 개요 셀 — |
closedAt 있음 (폐점 매장) | 헤더에 “폐점됨” danger 배지 + 정지/복구/폐점 액션 모두 숨김(terminal) + 개요에 “폐점일” danger highlight 행 |
closedAt == null · SUSPENDED 상태 | 헤더 [복구]+[폐점] 버튼 + 개요 “정지 사유” danger highlight 행(현재 StoreDetailResponse 미반영 → —, dtos 갭 노트 참고) |
closedAt == null · ACTIVE/INACTIVE 상태 | 헤더 [정지]+[폐점] 버튼 |
전이 불가 (409 STORE_INVALID_STATUS_TRANSITION) | 다이얼로그 인라인 danger — “현재 상태에서는 변경할 수 없습니다” |
이미 폐점 (409 STORE_ALREADY_CLOSED) | 폐점 다이얼로그 인라인 danger — “이미 폐점된 매장입니다” |
| 점장 0건 | ”발급된 점장 계정이 없습니다” 빈상태 + [점장 발급] CTA |
| 발급/관리 액션 성공 | 다이얼로그 router.refresh() → 상세(managers) 재검증 |
| 정지/복구/폐점 액션 성공 | 다이얼로그 router.refresh() → 상세(헤더 status·폐점됨 배지·개요) 재검증 |
| 활성 PL 미적용 (#055) | 개요 섹션 “적용된 플레이리스트 없음” 빈상태 + [적용] CTA |
| 활성 PL 적용 성공/해제 성공 (#055) | getGetStoreActivePlaylistQueryKey(storeId) invalidate → 섹션 즉시 갱신 |
활성 PL 본사 불일치 (409 STORE_PLAYLIST_HQ_MISMATCH) | 적용 다이얼로그 인라인 — “이 매장의 본사 플레이리스트만 적용할 수 있습니다” (FE 가 후보를 hqId 한정해 사전 차단) |
적용 PL 삭제됨 (404 PLAYLIST_NOT_FOUND) | 적용 다이얼로그 인라인 — “선택한 플레이리스트를 찾을 수 없습니다” |
| 폐점 매장 (#055) | 활성 PL 섹션 숨김 (terminal) |
References
- SPEC #036 · SPEC #020 (HQ 상세 — 대칭 원본) · SPEC #019 (매장 목록) · SPEC #021 (점장 발급) · SPEC #029 (점장 관리) · SPEC #055 (매장 활성 플레이리스트 — 적용/해제/조회) · SPEC #054 (플레이리스트 도메인)
linkmusic-frontend-space/apps/admin/src/app/(protected)/stores/[id]/(page.tsx·store-detail-client.tsx·store-active-playlist-section.tsx·store-active-playlist-apply-dialog.tsx·store-active-playlist-clear-dialog.tsx)