FeaturesHQ (본사)HQ Mode 대시보드·산하 매장 (apps/space /admin)

HQ Mode — 본사 대시보드 + 산하 매장 목록 (apps/space /admin)

SPEC #051 도입 (read 슬라이스). 실 HQ_MANAGER 직접 로그인 표면(apps/space · space.linkmusic.io) — 운영사 임퍼소네이션(apps/admin)이 아니다.

이 페이지는 apps/space 의 실 HQ_MANAGER 본사 모드다. features/hq/* 의 나머지 페이지(onboarding·list·detail·impersonation)는 운영사(OPERATOR)가 본사를 관리하는 apps/admin 화면이며 별개다.

Overview

HQ_MANAGER 가 apps/space 본사 모드(/admin)에서 본인 본사 대시보드(산하 매장 상태 요약)산하 매장 목록(검색·필터·페이지네이션) 을 본다. 전부 hqId 스코프(본인 본사만 — 토큰 주체에서 도출, 타 본사 접근 불가). #050 이 셸·로그인·topbar(getHqMe)까지 부팅했고, #051 이 /admin 대시보드 placeholder 를 실데이터로, /admin/stores 산하 매장 화면을 신규 추가한다.

시안 출처: workspace parent dir design_handoff_linkmusic/design/screens/hq-dashboard.jsx (StatCard 그리드)·hq-stores.jsx(테이블·필터·페이지네이션). 검색·필터·페이지네이션은 공용 ListToolbar/ListPagination(운영사 /stores #044 관용구)으로 정합.

대시보드 (/admin — server component)

apps/space/src/app/admin/page.tsx. getHqDashboard(GET /api/v1/hq/dashboard)를 server component 에서 refresh-aware(loadHqDashboardRefreshAware)로 호출한다. 401+refresh 실패 → /login fail-closed. 5xx·네트워크 일시 장애(data:null) → 셸은 유지하고 본문은 재시도 안내.

카드데이터
산하 매장totalStores + 상태별 배지(활성 activeStores·비활성 inactiveStores·정지 suspendedStores). 클릭 → /admin/stores
폐점 매장closedStores (closedAt 설정, 전체 합계 포함)
오늘 송출 미도달 매장실데이터(SPEC #119 F3·D4)getHqUndeliveredToday(HqUndeliveredTodayResponse.undeliveredStoreCount)를 60초 폴링(refetchInterval:60s·refetchIntervalInBackground:false). 오늘(KST) 송출 중 미도달(PENDING) distinct 매장 수. count>0 면 danger 강조(클릭 → /admin/audit 송출 이력), 0 이면 “모두 도달” 비강조. UndeliveredTodayCard client island(apps/space/src/app/admin/undelivered-today-card.tsx, data-testid=hq-dash-card-undelivered). 배지/카드 시각은 atom-grounded(전용 시안 부재, design-debt 등재).
활성 CM송·구독·결제·대기 문의”준비 중” placeholder — billing 도메인 미도착(SPEC #051 §F1)

bootstrap 진척 체크리스트 (SPEC #120 §D1 — #118 CTA 확장): 카드 그리드 위에 본사 첫 진입 “무엇부터 시작하면 좋을까요?” 진척 체크리스트를 노출한다(hq-dashboard-bootstrap). #118 의 단순 빈상태 CTA 를 완료/미완료 진척형으로 확장(중복 표시 X):

  • ① 매장 등록 — 산하 매장 수(totalStores) > 0 이면 완료(Check 아이콘), 아니면 [매장 등록] (hq-dashboard-bootstrap-create/admin/stores/new) CTA.
  • ② 안내방송 만들기 — 본사 안내방송 수 > 0 이면 완료, 아니면 [안내방송 만들기] (hq-dashboard-bootstrap-announcement/admin/announcements) CTA.

두 단계 모두 완료면 자동 숨김(노이즈 방지). 새 상태/BE 0(D1): 매장 수는 server 대시보드 데이터로, 안내방송 수는 안내방송 목록과 동일한 generated query(useListHqTtsAnnouncements total)로 파생한다. server data 엔 매장 수만 있으므로 체크리스트는 client island(bootstrap-checklist.tsx)이며, 안내방송 query 가 로딩/에러면 ② 를 미완료(항상-CTA)로 보수 판정한다(폴백). #119 UndeliveredTodayCard·기존 카드와 충돌 없이 빈 영역에 배치. 체크 아이콘·버튼·카드는 atom-grounded(전용 시안 부재, design-debt 등재).

산하 매장 목록 (/admin/stores)

page.tsx(server 셸) + hq-store-list-client.tsx(client). 운영사 /stores(#044)와 동일 이유로 서버사이드 페이지네이션 + client-query(useListHqStores) — q(매장명·주소)·status·type·page·size 를 client state 로 보유한다. 정렬은 서버 고정(status 우선순위→name asc→id asc).

  • 툴바: 공용 ListToolbar — 검색(매장명·주소, ≤100)·유형(ListHqStoresType)·상태(ListHqStoresStatus) 필터 + 적용/초기화.
  • 헤더 액션(SPEC #106·#111): 우측에 [CSV 일괄 등록] 버튼(ghost, hq-store-list-bulk) → /admin/stores/bulk + [+ 매장 등록] 버튼(primary, hq-store-list-create) → /admin/stores/new. #084 F2(단일)·F3(CSV 일괄) 마감 — 본사가 산하 매장을 직접 등록한다(사이드바 신설 회피, 목록 컨텍스트 유지). 권한 가드는 layout(server)이 HQ_MANAGER-only 로 처리.
  • : 매장명(+폐점 배지 if closedAt)·상태(StatusPill)·유형·주소·담당자(managerName)·점장 계정(hasManagerAccount → 발급됨/미발급).
  • 빈 상태: 필터 0건(ListNoResults CTA) vs 진짜 빈 목록 구분. 진짜 빈 목록(hq-store-list-empty)에는 bootstrap CTA(SPEC #118 §D3 / H1) — “무엇부터” 안내 + [매장 등록](hq-store-list-empty-create/admin/stores/new)·[안내방송 만들기](hq-store-list-empty-announcement/admin/announcements)를 본문에 노출한다(헤더 진입점을 빈 본문에서 한 번 더 명확히 — dead-end 완화).
  • 페이지네이션: 공용 ListPagination — 총 N개 + 이전/다음(경계 disabled).
  • 상세 진입점(SPEC #084): 매장명 셀 → /admin/stores/[id] (hq-store-link-{storeId}). HQ Mode 매장 상세 조회 페이지가 5 섹션(개요/점장 정보/활성 PL/운영 상태/메타)로 조회 + 매장 정보 인라인 편집(#105)을 제공. 개요는 담당자 연락 정보(managerName·managerEmail·phone, #116)를 노출하고 편집 폼은 그 현재값을 prefill 한다.

신규 매장 등록 (/admin/stores/new, SPEC #106)

page.tsx(server 셸) + hq-store-create-form.tsx(client). 본사 매장 편집(#105) 폼 idiom 미러 + CM송 등록(#093) 헤더/취소·등록 idiom 미러 — 단일 단계 폼으로 5 필드(name·address·managerName·managerEmail·managerPhone)를 입력하면 POST /api/v1/hq/stores(operationId createHqStore) 가 type 을 본사 유형으로 자동 결정해 201 + HqStoreDetailResponse 를 반환한다. 성공 시 ["/api/v1/hq/stores"] prefix 캐시 invalidate + 응답 id/admin/stores/{id} push.

  • 필수 표시: name 만 필수(Field required). 나머지 4 필드는 선택 — 빈 입력 → null payload(단순 nullable, #105 의 JsonNullable 분기 없음 — 신규 등록은 clear vs unchanged 구분 불필요).
  • 클라 검증(D4 backend OpenAPI 미러): name 1..50 비-blank · address ≤200 · managerName ≤50 · managerEmail @Email + ≤255 · managerPhone ≤30. 위반 시 Field error 노출 + [등록] disabled. 클라가 미리 reject 해 BE 호출 절약(frontend.md §8).
  • a11y: Field 가 라벨 + helper/error 슬롯 제공(role=alert error · helper id 자동 부여 ${htmlFor}-helper). 입력에 aria-invalid + aria-describedby 연관. Banner danger 는 role=alert (Banner 기본).
  • 취소: router.back() 우선, 히스토리 비어 있으면 /admin/stores push.
  • 에러 매핑: Banner tone="danger" (hq-store-create-error) — 400 HQ_STORE_INVALID_FIELD=“입력값이 올바르지 않습니다.” / 403 AUTH_HQ_SUSPENDED=“정지된 본사는 매장을 등록할 수 없습니다.” / 401·403 일반=“접근 권한이 없습니다.” / 5xx=“서버 오류가 발생했습니다.” / BACKEND_UNREACHABLE=“서버에 연결할 수 없습니다.” / 그 외=“매장 등록에 실패했습니다.”
  • audit 자동: BE 가 같은 트랜잭션으로 HqAuditAction.HQ_STORE_CREATED + HqAuditTargetType.STORE 1행 기록(detail {name, type, address, managerName} partial 스냅샷). 본사 감사 뷰·운영자 본사 감사 뷰 모두 “매장 등록”(info) 으로 노출.

CSV 일괄 등록 (/admin/stores/bulk, SPEC #111, #084 F3 마감)

page.tsx(server 셸) + bulk-client.tsx(client). #106 단일 등록을 행 단위로 미러한 MVP — 매장 마스터 5필드만(점장 계정 발급·영업시간은 후속). CSV 파일을 업로드하면 POST /api/v1/hq/stores/bulk(operationId bulkCreateHqStores, multipart file) 가 행별로 독립 처리해 200 + BulkCreateHqStoreResponse(부분 실패 결과 리포트)를 반환한다.

  • CSV 형식 안내: 페이지에 5컬럼 헤더 예시 노출(hq-store-bulk-csv-header) — 매장명,주소,담당자명,담당자이메일,담당자전화(UTF-8, 헤더 정확 일치). 샘플 다운로드는 후속(텍스트 안내로 MVP).
  • 파일 업로드: 점선 드롭존 시각(파일 선택 시 success 톤 전환·파일명/크기/행수 추정) + <input type="file" accept=".csv,text/csv">(hq-store-bulk-file, sr-only) + 업로드. 빈 파일은 BE 호출 전 클라가 가드(frontend.md §8). pending 시 업로드 단계 표시(파일 업로드→서버 처리). multipart 호출은 음원 업로드(#041) 패턴 미러 — generated useBulkCreateHqStores({ data: { file } }), FormData file 파트. (드래그앤드롭 실제 동작은 시각 힌트만 — 후속.)
  • 결과 리포트(200): 요약 카드(hq-store-bulk-report) — 성공/실패 비율 도넛 차트 + 중앙 총행수 + 큰 카운트(성공·실패), 톤 분기 카드 보더(allSuccess=success / 부분 실패=warn). + 실패 행만 노출(hq-store-bulk-failed-{rowNumber} — 행 번호·매장명·사유), 그룹핑 토글(행번호순 테이블 ↔ 사유별 <details> 묶음 — 표현만, 노출 필드 불변). 성공 행은 카운트로 충분. 실패 0이면 “전체 성공”(hq-store-bulk-no-failures). 성공분이 있으면 ["/api/v1/hq/stores"] prefix invalidate + 안내(hq-store-bulk-success-note) + 부분 실패 시 재업로드 루프 안내. sr-only “총 N행 처리 · 성공 N · 실패 M” 한 줄 요약(보조기술). [추가 업로드](폼 리셋) / [목록으로].
  • 부분 실패 구분(D2): 한 행 실패가 전체 롤백 X — 행 실패는 200 결과 리포트의 FAILED 행으로(주로 HQ_STORE_INVALID_FIELD). 파일 자체 오류(헤더 불일치·빈 파일·파싱 불가)만 400 HQ_STORE_BULK_INVALID_FILE 로 분리.
  • 에러 매핑: Banner tone="danger" (hq-store-bulk-error) — 400 HQ_STORE_BULK_INVALID_FILE=“CSV 형식 오류 — 헤더·파일을 확인해주세요.” / 403 AUTH_HQ_SUSPENDED=“정지된 본사는 매장을 등록할 수 없습니다.” / 401·403 일반=“접근 권한이 없습니다.” / 5xx=“서버 오류가 발생했습니다.” / BACKEND_UNREACHABLE=“서버에 연결할 수 없습니다.” / 그 외=“일괄 등록에 실패했습니다.”
  • 시안: design_9 정식 시안 정합 완료(design_handoff_linkmusic_9/design/screens/hq-stores-bulk-csv.jsx) — style(space)(시각만 교체, 동작·API·testid·에러매핑·a11y 보존). CSV 헤더 컬럼 칩·드롭존·업로드 단계·결과 도넛·그룹핑 토글·재업로드 안내 흡수. 드래그앤드롭/실패행 CSV 다운로드는 시각 힌트만(후속). design-debt §2 등재.
  • 후속: 점장 계정 자동 발급(FR-13.6)·영업시간/정기휴무 컬럼·검증→미리보기 2단계·csvId idempotency·CP949 자동감지(한국 Excel)·드래그앤드롭·샘플 다운로드.

사이드바

HQSidebar 의 “대시보드”(/admin)·“매장”(/admin/stores)·“플레이리스트”(/admin/playlists, #057) 항목이 enabled(활성 라우팅). 나머지 항목은 “준비 중”(disabled) 유지.

인가

/api/v1/hq/**hasRole("HQ_MANAGER") 1차 경계 + service PrincipalScopeGuard claim↔DB 재검증(#049). 미인증 401 · 비활성/role·소속 불일치 403 PRINCIPAL_SCOPE_MISMATCH. 클라이언트 목록 fetch 는 generated apiFetch 가 BFF catch-all /api/backend/... 경유(토큰 서버 전용).

Followups

  • 송출·정산 지표(방송·billing 도메인 도착 후 대시보드 확장) — §F1.
  • 본사 mutating: 매장 정보 편집(§F1) ✅ #105 마감 · 매장 등록(§F2) ✅ #106 마감 · CSV 일괄 등록(§F3) ✅ #111 마감(MVP 5필드) · 상태 전이 본사 위임(§F4) · 점장 계정 발급 본사 위임(§F5).
  • 플레이리스트 조회(read-only) 도착(#057)HQ Mode 플레이리스트 조회. 안내방송·라이브러리 등 나머지 Surface 기능은 후속.