FeaturesStore (매장)Store Detail (/stores/:id)

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/ui atom(PageHeader·Tabs/Tab·DescCard·StatusPill·테이블·Banner·점선 박스 빈상태)에 매핑했다. HQ 상세(ops-hq-detail)와 시각 일관. 기능·계약·상태 분기 불변(시각만).

Overview

운영자(OPERATOR)가 /stores 목록에서 매장명 셀을 클릭해 진입하는 단건 상세. 헤더(매장명 + status/type/plan pill) + 4탭. 정산(결제)·감사로그(활동) 도메인이 미구축이라 개요·점장 계정 2탭은 실데이터, 결제·활동 2탭은 “준비 중” placeholder. 매장 자체 상태 전이는 비목표(조회 전용).

#185 — 매장별 재생 설정 재도입(개요 탭 “재생 설정” 섹션). #055 의 활성 PL 섹션은 #170 에서 제거됐다가, 매장 활성 PL 의 쓰기 주체가 점장뿐이라는 문제(감사 R1·OP-F2)로 SPEC #185 에서 다시 들어왔다. 지금 구도는 3주체 병존이다:

  • 운영사 — 본사 기본 PL(playlist.is_default) + 이 매장의 활성 PL 지정/해제(본 섹션).
  • 점장 — player·/store/playlist 에서 자기 매장(또는 자기 기기)의 PL 을 선택(#129·#178).
  • 본사 — 읽기 전용 + 변경은 운영사 문의(#185 D4, HQ 매장 상세).

재도입판이 #055 와 다른 점은 해석 출처를 함께 보여준다는 것이다 — 이름만 띄우면 기기 지정 PL (#178)이 우선하는 매장에서 화면값과 실제 재생이 갈린다.

Spec

<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 행) · 생성일 · 수정일). 우측 “접속 상태”(마지막 온라인 · 마지막 하트비트). V74 — 점장 이름·이메일·전화는 매장이 아니라 계정에 있어 개요에서 뺐고, 점장 계정 탭에서 계정별로 본다. null 필드는 —. 카드 아래 전폭 재생 설정 섹션(#185 재도입).
점장 계정실데이터managers 목록 테이블 (이메일(+ 인증 대기 중이면 “변경 대기 → 새 주소 · 만료 시각”) · 이름 · 전화 · 상태 · 비밀번호(passwordMustChange → “변경 필요” 배지) · 최근 로그인 · 가입일 · ACTIVE 행만 [이메일 정정]). 카드 헤더 바 우측 [점장 발급](#021 StoreManagerIssueDialog — 계정이 있어도 노출: 회수만 남은 매장·추가 점장) · [관리](#029 StoreManagerManageDialog) · 빈상태 시 [점장 발급] CTA. 폐점(closedAt != null) 매장은 [점장 발급] CTA 를 숨기고 빈상태 본문을 “폐점된 매장입니다 — 점장 계정을 발급할 수 없습니다” 로 바꾼다(본사 매장 상세의 !isClosed 가드와 동일 규칙 — SPEC #184).
결제placeholder”준비 중 — 정산 도메인 후속” 점선 박스 빈상태 (정산 미구축)
활동placeholder”준비 중 — 감사로그 후속” 점선 박스 빈상태 (매장 감사로그 미구축)

소속 본사 표기(#185 D3) — 헤더 메타와 개요 “소속 본사” 행은 hqDisplayName 을 거친다. 가상 본사면 “독립 매장” 으로 표기해 같은 화면의 재생 설정 섹션(후보 PL 스코프 안내)과 문구가 갈리지 않게 한다. 매장 목록의 본사 컬럼도 동일 매핑.

탭 바는 @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)를 재검증한다.

발급 다이얼로그는 프리필하지 않는다(V74). 대신 이 매장의 회수(WITHDRAWN) 계정 이메일을 revokedEmails 로 넘겨 참고 목록으로 보여주고, 누를 때만 입력란을 채운다(회수 계정도 이메일을 계속 차지해 같은 주소로는 409 DUPLICATE_EMAIL).

점장 이메일 정정 (V74)

ACTIVE 점장 행의 [이메일 정정] → StoreManagerEmailCorrectionDialog(전용 시안 없음 — 발급 다이얼로그 atom 미러). POST /api/v1/admin/stores/{storeId}/managers/{managerId}/email (correctStoreManagerEmail).

계정 상태제출 전 안내결과
credentialSetAt·lastLoginAt 모두 null(아직 아무도 비밀번호를 정하거나 로그인하지 않음)“바로 새 주소로 바뀌고 새 주소로 계정 설정 메일이 다시 갑니다”REPLACED — 즉시 교체. setupEmailSent === false 면 [관리]에서 재발송 안내(danger)
그 밖(사용 중)“새 주소로 인증 링크가 가고, 1시간 안에 눌러야 바뀝니다. 기존 주소로는 알림이 갑니다”VERIFICATION_SENT — 표의 이메일 칸에 “변경 대기” 로 남고, 링크를 누르면 교체
  • 판정식은 BE 조건부 UPDATE 와 같은 두 필드다(rules §22-1). 안내는 제출 전 추정이고, 결과 화면은 응답 mode 가 권위다.
  • 검증: 이메일 형식·≤255, 현재 주소와 같으면(대소문자 무시) [정정] 비활성.
  • 에러: 409 DUPLICATE_EMAIL(“회수된 계정도 이메일을 차지합니다”) · 409 ACCOUNT_INVALID_STATUS_TRANSITION(새로고침 안내) · 404 · 429 · 403 은 인라인 danger Banner. 결과 화면으로 넘어가지 않는다.
  • 진행 중에는 Esc·바깥·[취소]가 잠긴다(frontend.md §18). 성공 시 router.refresh().
  • 옛 주소 알림은 새 주소를 가린다(r***@x.com) — 옛 주소의 주인이 제3자일 수 있다.

재생 설정 섹션 (#185 D1)

개요 탭 하단(매장 정보 카드 아래 전폭)의 “재생 설정” 섹션. StorePlaybackSection (+ StoreActivePlaylistDialog). 이 매장에서 지금 무엇이 재생되는가와 그 출처를 보여주고, 운영사가 그 자리에서 지정/해제한다.

왜 다시 들어왔나

StoreActivePlaylistController(GET/PUT/DELETE, OPERATOR-only)와 BFF 헬퍼는 #055 이후 계속 존재했지만 #170 에서 화면이 사라지며 UI 호출부가 0건이 됐다. 결과적으로 매장 활성 PL 의 유일한 쓰기 주체가 점장 본인이 되어, 본사 매장 30곳을 온보딩해도 운영사가 할 수 있는 건 본사 기본 PL 1개뿐이었고 매장별 편성은 점장에게 전화하는 수밖에 없었다(감사 R1·OP-F2). #185 에서 화면만 복원했다 — BE 계약·권한은 그대로(마이그레이션 0 · 신규 endpoint 0).

해석 출처 표시 (운영사가 관측 가능한 범위)

큐 PL 해석은 DEVICE > ACTIVE > DEFAULT 우선순위다(기기별 활성 PL). 섹션은 PL 이름 옆에 출처 배지를 항상 병기한다:

배지조건의미
매장 지정(success)getStoreActivePlaylist.active === true이 매장에 지정된 활성 PL 이 재생된다(본사 기본보다 우선)
본사 기본(info)활성 지정 없음 + 소속 본사에 isDefault PL 있음본사 기본 PL 이 fallback 으로 재생된다
없음 — 무음(danger)둘 다 없음점장 화면이 재생할 음악을 찾지 못한다(NO_ACTIVE_PLAYLIST) — danger 경고 배너 병기
  • DEVICE 는 운영사 화면에서 확정할 수 없다. 기기 목록·기기 PL endpoint 는 /api/v1/store/**(STORE_MANAGER 전용)이고 운영사용 미러가 없다. 그래서 출처를 DEVICE 로 단정하지 않고, “기기 지정이 있으면 그쪽이 우선하며 운영사 화면에서는 확인할 수 없다” 는 사실을 섹션 하단 주석으로 항상 노출한다(추측 표시 금지). → Known Gaps
  • 본사 기본 PL 판정은 useListPlaylists({ hqId, size: 100 }) 의 isDefault 로 한다. 목록에 isDefault 필터가 없어 상위 100건에서 찾으며, 못 찾았는데 뒤에 더 있으면 “없음” 이 아니라 “판정 불가” 안내를 낸다(멀쩡한 매장을 무음으로 오진하지 않기 위해).

지정 · 해제

액션동작
[플레이리스트 지정]/[변경]다이얼로그(mode=set) — 이름 검색 + 후보 목록(행별 [지정]). 후보는 매장 소속 본사(hqId) 스코프로 좁힌다(BE 가 store.hqId == playlist.hqId 를 강제 — 409 STORE_PLAYLIST_HQ_MISMATCH). 현재 적용 PL 은 “적용 중” 표기(재지정 불가), 본사 기본 PL 은 “본사 기본” 배지 병기. 상위 50건만 노출하고 잘리면 검색 안내.
[지정 해제]다이얼로그(mode=clear) — 해제 후 본사 기본 PL 로 되돌아가며, 기본 PL 이 없으면 그 순간 무음임을 경고한 뒤 clearStoreActivePlaylist(204, 멱등).
폐점(closedAt != null) 매장두 진입점 모두 숨김(헤더 액션·점장 발급과 같은 terminal 가드). 표시는 유지.
  • 성공 시 getStoreActivePlaylist query 만 invalidate + 섹션에 success 배너(전체 router.refresh() 아님). 실패는 다이얼로그에 머무르며 사유를 노출한다(성공 배너를 만들지 않는다) — 실패를 뭉개면 운영자가 지정됐다고 믿고 떠나 매장이 무음으로 남는다.
  • 독립(INDEPENDENT) 매장의 후보는 가상 본사 PL 이다 — 그 PL 을 만드는 경로가 #185 D2 로 열렸다 (Playlist).

Implementation

  • server: app/(protected)/stores/[id]/page.tsx — backendGetStoreDetail(accessToken, id) server fetch. refresh-aware(refreshIfNeeded → 401 forceRefresh 1회 재시도 → destroy+redirect), 404 STORE_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).
  • #185: 개요 탭의 재생 설정 섹션 = store-playback-section.tsx(표시·출처 파생·query invalidate)
    • store-active-playlist-dialog.tsx(set/clear 2모드). generated 훅 useGetStoreActivePlaylist·useSetStoreActivePlaylist·useClearStoreActivePlaylist 를 쓴다 (#170 이후 orphan 이던 훅·엔드포인트가 다시 호출부를 가진다). server-side helper backendGetStoreActivePlaylist·backendSetStoreActivePlaylist·backendClearStoreActivePlaylist (lib/backend)는 계속 단일 소스로 유지(현재 화면은 client 훅 경로를 쓴다).

endpoint / DTO

  • GET /api/v1/admin/stores/{id} — getStoreDetail, OPERATOR-only. → StoreDetailResponse.
  • 점장 항목 = StoreManagerSummaryItem (SUSPENDED·WITHDRAWN 포함).
  • 운영사 활성 PL 엔드포인트 getStoreActivePlaylist(GET)·setStoreActivePlaylist(PUT)·clearStoreActivePlaylist(DELETE) /api/v1/admin/stores/{storeId}/active-playlist(OPERATOR-only) — #185 에서 UI 호출부 복원 (#170~#184 구간에는 orphan 이었다). 응답 StoreActivePlaylistResponse · 요청 SetStoreActivePlaylistRequest.
  • 후보 PL 조회 GET /api/v1/admin/playlists?hqId=(listPlaylists) — 기본 PL 판정(isDefault)에도 같은 응답을 쓴다.

States & Edge Cases

상태처리
존재하지 않는 id404 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 지정 성공 (#185)getStoreActivePlaylist invalidate + 섹션 success 배너(”… 지정했습니다”)
활성 PL 지정 실패 (409 STORE_PLAYLIST_HQ_MISMATCH·404 PLAYLIST_NOT_FOUND)다이얼로그 유지 + 사유 노출, 성공 배너 없음
활성 PL·본사 기본 PL 모두 없음출처 배지 “없음 — 무음” + danger 경고(“이 매장은 지금 음악이 나오지 않습니다”)
본사 PL 이 100건 초과 + 상위에 기본 PL 없음무음 단정 대신 “기본 지정 여부 확정 불가” info 안내
기기(store_device)에 별도 PL 지정됨운영사 화면에서 조회 불가 — “기기 지정이 우선” 주석만 노출(#178 · Known Gaps)

References

  • SPEC #036 · SPEC #020 (HQ 상세 — 대칭 원본) · SPEC #019 (매장 목록) · SPEC #021 (점장 발급) · SPEC #029 (점장 관리) · SPEC #055 (매장 활성 PL 최초) · SPEC #170 (매장별 활성 PL 지정 제거) · SPEC #185 (재생 설정 섹션 재도입 · 해석 출처 표시) · SPEC #129 (점장 활성 PL 선택) · SPEC #178 (기기별 PL) · SPEC #054 (플레이리스트 도메인)
  • linkmusic-frontend-space/apps/admin/src/app/(protected)/stores/[id]/ (page.tsx · store-detail-client.tsx · store-playback-section.tsx · store-active-playlist-dialog.tsx)