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. 매장 자체 상태 전이는 비목표(조회 전용).
#170 — 매장별 활성 PL 지정 제거. 과거(#055) 개요 탭 하단에 운영사가 매장별 활성 플레이리스트를 적용/해제하던 섹션이 있었으나, 재생 모델 확정에 따라 제거됐다. 이제 운영사는 Playlist 화면에서 본사 기본(디폴트) PL 만 정하고(모든 매장 통합), 매장이 실제로 무엇을 틀지는 점장이 player 에서 선택한다(#129). 본사(space) 매장 상세는 점장이 고른 활성 PL 을 읽기전용으로만 표시한다.
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 필드는 —. ( |
| 점장 계정 | 실데이터 | managers 목록 테이블 (이메일 · 이름 · 상태 · 비밀번호(passwordMustChange → “변경 필요” 배지) · 최근 로그인 · 가입일). 카드 헤더 바 우측 [관리](#029 StoreManagerManageDialog) · 빈상태 시 [점장 발급](#021 StoreManagerIssueDialog) CTA. 폐점(closedAt != null) 매장은 [점장 발급] CTA 를 숨기고 빈상태 본문을 “폐점된 매장입니다 — 점장 계정을 발급할 수 없습니다” 로 바꾼다(본사 매장 상세의 !isClosed 가드와 동일 규칙 — SPEC #184). |
| 결제 | 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) — 제거됨 (#170)
과거 개요 탭 하단에 운영사가 매장별 활성 PL 을 적용/해제/조회하던 섹션(StoreActivePlaylistSection
- apply/clear 다이얼로그)이 있었으나 #170 에서 제거됐다. 재생 모델 확정에 따라 매장별 활성 PL 지정은 모델과 어긋나기 때문이다:
- 운영사는 Playlist 화면에서 본사 기본(디폴트) PL 만 정한다(모든
매장 통합,
playlist.is_default). 매장별로 다른 PL 을 지정하지 않는다. - 점장이 실제로 무엇을 틀지 player 에서 직접 선택한다(#129,
PATCH /store/me/active-playlist). 활성 PL 이 없으면 본사 기본 PL 로 fallback(#058). - 본사(space) 매장 상세는 점장이 고른 활성 PL 을 읽기전용으로만 표시한다(유지).
제거된 FE: store-active-playlist-section.tsx·store-active-playlist-apply-dialog.tsx·
store-active-playlist-clear-dialog.tsx(+ 테스트). 이들이 쓰던 generated 훅
(useGetStoreActivePlaylist·useSetStoreActivePlaylist·useClearStoreActivePlaylist)과 BE
운영사 엔드포인트(GET/PUT/DELETE /api/v1/admin/stores/{id}/active-playlist)는 UI 미사용
orphan 으로 남는다(BE 후속 cleanup SPEC).
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). - #170: 과거 개요 탭 활성 PL 섹션 컴포넌트(
store-active-playlist-section.tsx+ apply/clear 다이얼로그)는 제거됐다. server-side helperbackendGetStoreActivePlaylist·backendSetStoreActivePlaylist·backendClearStoreActivePlaylist(lib/backend)와 generated 훅은 UI 미사용 orphan 으로 남는다.
endpoint / DTO
GET /api/v1/admin/stores/{id}—getStoreDetail, OPERATOR-only. →StoreDetailResponse.- 점장 항목 =
StoreManagerSummaryItem(SUSPENDED·WITHDRAWN 포함). - #170 orphan: 운영사 활성 PL 엔드포인트
getStoreActivePlaylist(GET)·setStoreActivePlaylist(PUT)·clearStoreActivePlaylist(DELETE)/api/v1/admin/stores/{storeId}/active-playlist(OPERATOR-only)는 UI 미사용 orphan(#170 에서 화면 제거, BE 후속 cleanup).
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 |
| 점장 0건 · 폐점 | 빈상태 본문 “폐점된 매장입니다 — 점장 계정을 발급할 수 없습니다” (CTA 없음) |
| 발급/관리 액션 성공 | 다이얼로그 router.refresh() → 상세(managers) 재검증 |
| 정지/복구/폐점 액션 성공 | 다이얼로그 router.refresh() → 상세(헤더 status·폐점됨 배지·개요) 재검증 |
| 운영사 매장별 활성 PL 지정 제거 — 본사 기본 PL(운영사)·점장 선택(#129)으로 대체 |
References
- SPEC #036 · SPEC #020 (HQ 상세 — 대칭 원본) · SPEC #019 (매장 목록) · SPEC #021 (점장 발급) · SPEC #029 (점장 관리) · SPEC #170 (매장별 활성 PL 지정 제거) · SPEC #055 (제거된 매장 활성 PL) · SPEC #129 (점장 활성 PL 선택) · SPEC #054 (플레이리스트 도메인)
linkmusic-frontend-space/apps/admin/src/app/(protected)/stores/[id]/(page.tsx·store-detail-client.tsx)