FeaturesStats (통계 전용 /stats)

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 경유) — 응답 타입은 generated AdminStatsResponse 단일 소스(수기 재정의 금지).
  • 갱신 정책(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 = 누적 합)BEStatCard
송출 도달률dispatch.played / (dispatch.pending + dispatch.played)FEStatCard
매장 활동 (7일)storeAuditActivity7dBEStatCard
송출 상태 분포dispatch.{played, pending, scheduled, canceled, missed}BEStackBar
CS 티켓 해결률(tickets.resolved + tickets.closed) / totalFE(tickets.*)StackBar
최근 30일 송출 추이dispatchTrend30d[]BESignupTrend(metricLabel="송출")
  • 송출 추이는 가입 추이와 동일한 buildTrendPoints(30일 축 0 채움)·SignupTrend 컴포넌트를 미러한다. SignupTrendmetricLabel prop(기본 “본사 가입”)으로 빈 상태 문구·aria-label 을 지표명에 맞춘다.
  • 도달률·해결률ratioPercent(분모 0 → null → ”—“)로 파생. 도달률 분모는 재생 시도 (PENDING + PLAYED), SCHEDULED·CANCELED·MISSED(SPEC #143 — 미재생 만료) 는 시도에서 제외한다.
  • 송출 상태 분포MISSED(SPEC #143 — 도래+grace 초과 자동 만료)를 muted(회색) 톤으로 함께 표시한다(라벨 “미재생(만료)”). 도달률 분모에선 빠지지만 분포·CSV(송출,MISSEDisAllEmpty 합산엔 포함된다.
  • 송출/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 캐시를 분포 영역과 공유하므로 데이터 없으면 버튼 비활성.

버튼파일명포맷
지표 CSVlinkmusic-stats-snapshot-YYYYMMDD.csv분류,지표,값 평면(본사·매장·계정·CS티켓·송출·활동 29행)
추이 CSVlinkmusic-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.tsx
  • linkmusic-frontend-space/apps/admin/src/app/(protected)/stats/stats-distribution.tsx
  • linkmusic-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.tsx
  • linkmusic-frontend-space/apps/admin/src/lib/format.ts (recentKstDayKeys)