Dashboard (/)
SPEC #006 §2-7 · #012 PR-A(셸) · #017(실 stats 연동) · #035(분포·추이 확장) · #126(송출 요약) 정합. handoff 10-surface §4 Page 1.
Overview
로그인 직후 첫 화면. 운영사가 시스템 핵심 지표를 스캔하는 허브. 계산 가능한 시스템 지표는
GET /api/v1/admin/stats(OPERATOR-only) 실데이터로 연동(SPEC #017, #035 에서 분포·추이 확장,
#126 에서 본문 “최근 방송” 송출 요약 실데이터화). 도메인이 없는 지표(장애·활동)만 정직한 “준비 중”으로
둔다 — CS 티켓·계정·매장 분포는 #035, 송출은 #126 에서 실데이터화.
데이터 소스
- generated react-query 훅
useStats(@linkmusic/api-client, BFF/api/backend/api/v1/admin/stats경유) — 응답 타입은 generatedAdminStatsResponse단일 소스(수기 재정의 금지). - 갱신 정책(SPEC #017 §4): react-query on-load + 수동 refetch. 자동 폴링 없음(
QueryProvider기본staleTime30s). PageHeader 의 [지금 갱신] 버튼이 stats query 를 invalidate.
6 stat 카드 (전부 실데이터 — #035 에서 placeholder 제거)
| 카드 | 지표 | 출처 |
|---|---|---|
| 활성 본사 | hqsByStatus.active (delta = 총 totalHqs · 온보딩 N) | 실데이터 |
| 전체 매장 | totalStores | 실데이터 |
| 신규 본사 (7일) | newHqs7d | 실데이터 |
| 진행 중 임퍼소네이션 | activeImpersonations | 실데이터 |
| 미납 본사 | hqsByStatus.unpaid (delta = 정지 hqsByStatus.suspended) | 실데이터 |
| 미해결 티켓 | tickets.open + tickets.inProgress (delta = 긴급 urgentOpen · 미배정 unassignedOpen) | 실데이터 (#035) |
hqsByStatus.onboarding·hqsByStatus.suspended·totalHqs 는 위 카드의 delta(보조 라벨)로 노출.
미해결 티켓 카드는 urgentOpen > 0 이면 danger tone + 펄스 도트로 강조한다.
분포·추이 카드 (#035 — div 막대, 차트 라이브러리 없음)
전용 통계 페이지
/stats(→ Stats)는 동일 분포·추이를 stat 카드/본문 없이 상세히 펼친 별도 뷰다(공유 컴포넌트 재사용, 중복 회피). 대시보드는 종합 허브로 유지.
stat 카드 아래 분포 그리드(3-col) + 전폭 추이 카드. ops-dashboard-stats 시안 정합 — 각 카드는
StatDistCard 셸(헤더 “합계 N”)로 감싸고, 상태 분포는 StackBar(누적 가로 막대 + 범례), 라벨별
절대값 비교는 MiniBar(행별 가로 막대), 30일 추이는 SignupTrend(세로 막대 + 헤더 합계/일평균)로 렌더.
| 카드 | 지표 | 컴포넌트 |
|---|---|---|
| 매장 상태 분포 | storesByStatus.{active, suspended, inactive} | StackBar |
| 계정 분포 (활성) | accounts.{operatorsActive, hqManagersActive, storeManagersActive} | MiniBar |
| CS 티켓 지표 | tickets.{unassignedOpen, urgentOpen, inProgress} | MiniBar |
| 본사 유형 | hqsByType.{franchise, independent} | MiniBar |
| 본사 상태 분포 | hqsByStatus.{active, onboarding, unpaid, suspended} | StackBar |
| 최근 30일 본사 가입 추이 | hqSignups30d[] | SignupTrend |
데이터·계약 불변 —
AdminStatsResponse의 기존 필드만 사용한다(요금제 분포는 응답에 없어 미표시).StatDistCard/StackBar는 시안 §ops-phase4-shared 의 atom 을 admin 로컬 컴포넌트로 포트한 것.
- 30일 축 0 채움: BE 는 가입 있는 날만 반환 → FE
buildTrendPoints가 오늘(KST) 기준 최근 30일 축(recentKstDayKeys)을 만들어 빈 날count=0으로 채운다(매칭은 ISO date 문자열 비교, KST 기준일). 빈 날 막대는surface-3트랙으로 시각 구분(시안 §TrendBars). - 추이 빈 상태: 30일 전부 0 이면 “최근 30일 가입 추이 없음” 표기.
- a11y: 막대 트랙은
aria-label/title(라벨·값), 추이는role="img"+ 막대별 날짜·건수title.
본문 카드
최근 방송 (송출 요약 — SPEC #126, 실데이터)
기존 “방송 집계 준비 중” placeholder 를 송출 요약 실데이터로 교체했다. useGetAdminStats
응답이 200 일 때만 실데이터(badge “실시간”), 로딩·에러 시에는 준비 중 placeholder
(dash-broadcast-empty, badge “준비 중”)를 유지한다(빈 상태 회귀 0).
| 요소 | 지표 | testid |
|---|---|---|
| 요약 문구 | 최근 7일 송출 dispatch.last7dCount건 · 도달 reach% | dash-broadcast-summary |
| status 미니 분포 | dispatch.{played, pending, scheduled, canceled} (MiniBar) | dash-broadcast-bar |
- 도달률 =
played / (played + pending)— 분모 0 이면 ”—“(reachPercent헬퍼). - 송출 추이·도달률 상세는 전용 페이지
/stats(→ Stats)에 펼쳐진다.
v1 미연결 카드(의존 도메인 미도착)
아래 카드는 시안에는 있으나 의존 도메인이 아직 없어 정직한 빈 상태(“준비 중”)로 둔다. 하드코딩 더미는 모두 제거했고, 해당 도메인 도착 시 후속 SPEC 에서 실데이터로 연결한다.
| 카드 | 빈 상태 문구 | 미도착 의존 도메인 |
|---|---|---|
| 최근 장애 | ”장애 집계 준비 중입니다.” | 매장 heartbeat·장애 알림 백본(전무) |
| 최근 활동 | ”활동 피드 준비 중입니다.” | 감사 로그 집계 피드(OperatorAuditLog 는 있으나 대시보드 피드 미연결) |
| Topbar 장애 배지 | incidentCount 기본 0(배지 숨김), 가짜 “동기화 12초 전” 펄스 제거 | 장애 도메인 |
CS 티켓 본문: #035 에서 기존 “대기 CS 티켓” placeholder 를 교체했고, 티켓 자체는 분포 카드(
tickets.*)로 실데이터화됐다.
Implementation
// apps/admin/src/app/(protected)/page.tsx — server 컴포넌트(셸만)
// apps/admin/src/app/(protected)/dashboard-stats.tsx — 'use client' + useStats
const DashboardStats = () => {
const { data, isLoading, isError, refetch } = useStats();
if (isError) return <StatsError onRetry={() => void refetch()} />;
if (isLoading || !data) return <StatGridSkeleton />;
return (
<>
<StatGrid stats={data.data} /> {/* 6 실데이터 카드 */}
<DistributionGrid stats={data.data} /> {/* #035 분포·추이 미니바 */}
{/* + 준비 중 빈 상태 섹션들(장애·방송·활동) */}
</>
);
};페이지는 server 컴포넌트로 유지하고, react-query 가 필요한 stat 그리드 + 본문 영역만 client 하위
컴포넌트(DashboardStats)로 분리한다. 분포·추이 막대는 components/dashboard/stack-bar.tsx·
mini-bar.tsx·signup-trend.tsx(차트 라이브러리 없이 div 막대), 분포 카드 셸은
stat-dist-card.tsx, 30일 축 정규화는 lib/format.ts recentKstDayKeys.
States & Edge Cases
| 상태 | 처리 |
|---|---|
| 첫 로그인 (passwordMustChange) | (protected)/layout.tsx 가 server-side 로 /onboarding/change-password redirect — 대시보드 도달 X |
| 로딩 (isLoading) | 6 카드 자리 스켈레톤(stats-loading, 레이아웃 시프트 회피) |
| 에러 (isError — BFF 5xx·네트워크) | “지표를 불러오지 못했습니다” + 다시 시도. 셸은 유지, 본 영역만 에러 |
| 본사/매장 0건 (초기) | stat 카드 0 표시(정상 시나리오) |
| 30일 가입 0건 (#035) | 추이 카드 “최근 30일 가입 추이 없음” 빈 상태 |
Constraints
- OPERATOR 전용 endpoint (BE 가드). ops 셸(
(protected))은 OPERATOR 만 통과. - 실 stats 는 계산 가능한 지표만 — 장애·방송은 도메인 미존재라 placeholder (
[[memory: feedback_design_only_from_handoff]]). - 차트 라이브러리 도입 안 함 — 분포·추이는 div/CSS 막대(
ops-dashboard-stats시안 정합, 실 구현도 동일 제약).
References
- SPEC #006 §2-7 · #012 PR-A · #017 · #035 · #126
- handoff
10-surface-ops-backoffice.md§4 Page 1 linkmusic-frontend-space/apps/admin/src/app/(protected)/page.tsxlinkmusic-frontend-space/apps/admin/src/app/(protected)/dashboard-stats.tsxlinkmusic-frontend-space/apps/admin/src/components/dashboard/mini-bar.tsx·signup-trend.tsxlinkmusic-frontend-space/apps/admin/src/lib/format.ts(recentKstDayKeys·kstDayKey)