Stats (/stats)
SPEC #017(실 stats 연동) · #035(분포·추이) · #126(운영 지표 확장) 정합. handoff screens/ops-dashboard-stats.jsx.
페이지 라벨은 “운영 통계”(무료 MVP·결제 제외 정합 — 매출/MRR 미언급). 운영(매장·계정·CS·송출) 지표만 노출한다.
Overview
운영사 백오피스 사이드바 통계 항목의 전용 상세 페이지. 이전에는 ComingSoon
placeholder(SPEC #030)였으나, ops-dashboard-stats 시안의 분포 그리드 + 30일 가입 추이를
GET /api/v1/admin/stats(OPERATOR-only) 실데이터로 구현했고, #126 에서 송출(announcement
dispatch) 운영 지표(분포·도달률·7일/30일 추이·매장 audit 활동)와 CS 티켓 해결률(FE 파생)을
확장했다.
대시보드(/)와의 차별:
- 대시보드 = 상단 6 stat 카드 + 분포·추이 + 본문(장애·방송·활동) 셸까지 포함한 종합 허브.
/stats= 분포·추이만 상세히 펼친 전용 통계 뷰. stat 카드·본문 placeholder 없음(중복 회피).
공유 카드/차트 컴포넌트(StatDistCard·StackBar·MiniBar·SignupTrend)와 30일 축 정규화
헬퍼(recentKstDayKeys·buildTrendPoints)는 대시보드와 동일하게 재사용한다.
데이터 소스
- generated react-query 훅
useGetAdminStats(@linkmusic/api-client, BFF/api/backend/api/v1/admin/stats경유) — 응답 타입은 generatedAdminStatsResponse단일 소스(수기 재정의 금지). - 갱신 정책(SPEC #017 §4): on-load + 수동 refetch(자동 폴링 없음). PageHeader 의 [갱신]
버튼(
StatsRefreshButton)이 stats query 를 invalidate.
분포 카드 (시안 §ops-dashboard-stats — div 막대, 차트 라이브러리 없음)
| 카드 | 지표 | 컴포넌트 |
|---|---|---|
| 매장 상태 분포 | storesByStatus.{active, suspended, inactive} | StackBar |
| 계정 분포 | accounts.{storeManagersActive, hqManagersActive, operatorsActive, operatorsSuspended} | MiniBar |
| CS 티켓 상태 | tickets.{open, inProgress, resolved, closed} | StackBar |
| 본사 유형 | hqsByType.{franchise, independent} | MiniBar |
| 본사 상태 | hqsByStatus.{active, onboarding, unpaid, suspended} | StackBar |
| 최근 30일 본사 가입 추이 | hqSignups30d[] | SignupTrend |
송출·CS 운영 지표 (SPEC #126)
| 카드 | 지표 | 파생 | 컴포넌트 |
|---|---|---|---|
| 최근 7일 송출 | dispatch.last7dCount (delta = 누적 합) | BE | StatCard |
| 송출 도달률 | dispatch.played / (dispatch.pending + dispatch.played) | FE | StatCard |
| 매장 활동 (7일) | storeAuditActivity7d | BE | StatCard |
| 송출 상태 분포 | dispatch.{played, pending, scheduled, canceled, missed} | BE | StackBar |
| CS 티켓 해결률 | (tickets.resolved + tickets.closed) / total | FE(tickets.*) | StackBar |
| 최근 30일 송출 추이 | dispatchTrend30d[] | BE | SignupTrend(metricLabel="송출") |
- 송출 추이는 가입 추이와 동일한
buildTrendPoints(30일 축 0 채움)·SignupTrend컴포넌트를 미러한다.SignupTrend는metricLabelprop(기본 “본사 가입”)으로 빈 상태 문구·aria-label 을 지표명에 맞춘다. - 도달률·해결률은
ratioPercent(분모 0 →null→ ”—“)로 파생. 도달률 분모는 재생 시도 (PENDING + PLAYED),SCHEDULED·CANCELED·MISSED(SPEC #143 — 미재생 만료) 는 시도에서 제외한다. - 송출 상태 분포는
MISSED(SPEC #143 — 도래+grace 초과 자동 만료)를 muted(회색) 톤으로 함께 표시한다(라벨 “미재생(만료)”). 도달률 분모에선 빠지지만 분포·CSV(송출,MISSED)·isAllEmpty합산엔 포함된다. - 송출/audit 합도 데이터 0 빈 상태(
isAllEmpty) 판정에 포함된다.
데이터·계약 불변 —
AdminStatsResponse의 기존 + #126 신규 필드만 사용한다.시안 대비 미표시·정합:
- 요금제 분포(TRUST/AI) 카드는 응답에 대응 필드가 없어 미표시(임의 수치 금지).
- 시안의 매장 상태 4종(정상/비활성/정지/폐점)은 응답 enum 3종(active/suspended/inactive)에 맞춤 — 폐점 별도 카운트 없음.
- 시안의 본사 유형 3종(가맹/직영/개인)은 응답 2종(franchise/independent)에 맞춤 — 직영 별도 카운트 없음.
- 시안 추이는 “매장 가입”이나 응답 필드는
hqSignups30d= 본사 가입 추이(필드 정합).
- 30일 축 0 채움: BE 는 가입 있는 날만 반환 → FE
buildTrendPoints가 오늘(KST) 기준 최근 30일 축(recentKstDayKeys)을 만들어 빈 날count=0으로 채운다. - a11y: 막대 트랙은
aria-label/title(라벨·값), 추이는role="img"+ 막대별 날짜·건수.
Implementation
// apps/admin/src/app/(protected)/stats/page.tsx — server 컴포넌트(셸만)
// apps/admin/src/app/(protected)/stats/stats-distribution.tsx — 'use client' + useGetAdminStats
// apps/admin/src/app/(protected)/stats/stats-refresh-button.tsx — 'use client' + invalidate
const StatsDistribution = () => {
const { data, isLoading, isError, refetch } = useGetAdminStats();
if (isError) return <DistributionError onRetry={() => void refetch()} />;
if (isLoading || !data || data.status !== 200) return <DistributionSkeleton />;
if (isAllEmpty(data.data)) return <DistributionEmpty />;
return <DistributionContent stats={data.data} />;
};States & Edge Cases
| 상태 | 처리 |
|---|---|
| 로딩 (isLoading) | 분포 카드 3자리 스켈레톤(stats-loading, 시안 §loading — 레이아웃 시프트 회피) |
| 에러 (isError — BFF 5xx·네트워크) | “통계를 불러오지 못했습니다” + 다시 시도. 셸 유지, 본 영역만 에러 |
| 데이터 0 (모든 분포 + 송출/audit 합 0) | 시안 §empty 빈 상태(stats-empty — “표시할 통계 데이터가 없습니다”) |
| 30일 가입 0건 | 추이 카드만 “최근 30일 본사 가입 추이 없음” 빈 상태(dist-signups-trend-empty) |
| 30일 송출 0건 | 추이 카드만 “최근 30일 송출 추이 없음” 빈 상태(dist-dispatch-trend-bars-empty) |
| 송출 도달·티켓 해결률 분모 0 | ”—” 표시(나누기 회피) |
CSV 내보내기 (SPEC #133)
헤더 primary 에 [지표 CSV]·[추이 CSV] 2종(stats-export-snapshot·stats-export-trend, <StatsCsvExport>).
이미 useGetAdminStats 로 패칭된 AdminStatsResponse 를 클라이언트에서 직렬화·다운로드한다 —
별도 BE endpoint 없음(추가 왕복 0, 통계는 단일 snapshot). react-query 캐시를 분포 영역과 공유하므로
데이터 없으면 버튼 비활성.
| 버튼 | 파일명 | 포맷 |
|---|---|---|
| 지표 CSV | linkmusic-stats-snapshot-YYYYMMDD.csv | 분류,지표,값 평면(본사·매장·계정·CS티켓·송출·활동 29행) |
| 추이 CSV | linkmusic-stats-trend-YYYYMMDD.csv | 일자,지표,건수 long(KST 최근 30일 축, 본사가입·송출 각 30행·빈 날 0) |
- CSV 안전: UTF-8 BOM(Excel 한글) + RFC4180 escape(콤마·따옴표·개행 셀 따옴표 감싸기·내부 따옴표 이중화).
- 파일명 날짜는 KST(
kstDayKey). 행 구분 CRLF. - 기간(날짜 범위) 선택은 범위 밖(design handoff 부재 —
[[memory: feedback_design_only_from_handoff]]). 현재 stats 고정 윈도(최근 7일·30일)를 그대로 내보낸다. 기간 선택은 핸드오프 도착 후 별도 SPEC.
Constraints
- OPERATOR 전용 endpoint (BE 가드). ops 셸(
(protected))은 OPERATOR 만 통과. - 차트 라이브러리 도입 안 함 — 분포·추이는 div/CSS 막대(시안 정합, 실 구현도 동일 제약).
- 시안에 있으나 응답 필드 없는 수치(요금제 TRUST/AI·매장 폐점·본사 직영)는 표시하지 않는다
(
[[memory: feedback_design_only_from_handoff]]— 임의 수치 발명 금지).
References
- SPEC #017 · #035 · #126
- handoff
screens/ops-dashboard-stats.jsx·ops-phase4-shared.jsx(§StatDistCard·§StackBar·§MiniBarRow·§TrendBars) linkmusic-frontend-space/apps/admin/src/app/(protected)/stats/page.tsxlinkmusic-frontend-space/apps/admin/src/app/(protected)/stats/stats-distribution.tsxlinkmusic-frontend-space/apps/admin/src/app/(protected)/stats/stats-csv-export.tsx(SPEC #133 — CSV 직렬화·다운로드)linkmusic-frontend-space/apps/admin/src/components/dashboard/stack-bar.tsx·mini-bar.tsx·signup-trend.tsx·stat-dist-card.tsxlinkmusic-frontend-space/apps/admin/src/lib/format.ts(recentKstDayKeys)