diff --git a/src/features/crm/CsHome/ActiveCustomerTrendLine.jsx b/src/features/crm/CsHome/ActiveCustomerTrendLine.jsx new file mode 100644 index 0000000..0aba8ec --- /dev/null +++ b/src/features/crm/CsHome/ActiveCustomerTrendLine.jsx @@ -0,0 +1,72 @@ +import { useMemo } from 'react' + +// 최근 N일 활성 고객 수 — 미니멀 라인. +// 선 + 점 + 각 점 위 숫자 + 아래 날짜만. (격자/영역/그림자/툴팁 없음) +const W = 720, H = 180 +const X0 = 30, X1 = W - 30 // 좌우 여백 +const YTOP = 52, YBOT = 132 // 선이 그려지는 세로 밴드 +const ACCENT = '#2e7d32' + +const parseYmd = (s) => { + const [y, m, d] = String(s).split('-').map(Number) + return { m, d } +} + +export default function ActiveCustomerTrendLine({ points }) { + const model = useMemo(() => { + const pts = (points ?? []) + .map((p) => ({ ...parseYmd(p.date), count: Number(p.count ?? 0) })) + .filter((p) => Number.isFinite(p.count)) + if (!pts.length) return { pts: [] } + + const counts = pts.map((p) => p.count) + let lo = Math.min(...counts), hi = Math.max(...counts) + // 선이 밴드 가운데쯤 오도록 살짝 여백 + const pad = lo === hi ? Math.max(1, Math.round(Math.abs(hi) * 0.08)) : (hi - lo) * 0.6 + lo -= pad; hi += pad + if (lo === hi) hi = lo + 1 + + const n = pts.length + const xOf = (i) => (n === 1 ? (X0 + X1) / 2 : X0 + ((X1 - X0) * i) / (n - 1)) + const yOf = (v) => YBOT - ((v - lo) / (hi - lo)) * (YBOT - YTOP) + + const xy = pts.map((p, i) => [xOf(i), yOf(p.count)]) + const linePath = xy + .map((p, i) => `${i === 0 ? 'M' : 'L'} ${p[0].toFixed(2)},${p[1].toFixed(2)}`) + .join(' ') + + return { pts, xOf, yOf, linePath } + }, [points]) + + if (!model.pts.length) { + return
최근 활성 고객 데이터가 없습니다.
+ } + + const { pts, xOf, yOf, linePath } = model + const lastIdx = pts.length - 1 + + return ( + + + + {pts.map((p, i) => { + const cx = xOf(i), cy = yOf(p.count) + const isLast = i === lastIdx + return ( + + + + {p.count.toLocaleString()} + + + {p.m}/{p.d} + + + ) + })} + + ) +} diff --git a/src/features/crm/CsHome/CsHome.css b/src/features/crm/CsHome/CsHome.css index b14ee99..599c0ae 100644 --- a/src/features/crm/CsHome/CsHome.css +++ b/src/features/crm/CsHome/CsHome.css @@ -111,6 +111,43 @@ color: #2e7d32; } +/* 추이 라인차트 */ +.cshome-chart-card { + background: #fff; + border: 1px solid #eceef0; + border-radius: 10px; + padding: 14px 16px; + margin-top: 12px; +} + +.cshome-chart-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 6px; +} + +.cshome-chart-title { + font-size: 12px; + font-weight: 600; + color: #5b6b60; +} + +.cshome-chart { + display: block; + width: 100%; + height: auto; +} + +.cshome-chart-empty { + padding: 28px 0; + text-align: center; + font-size: 13px; + color: #9aa5a0; +} + /* 드라이버 상태 LED */ .cshome-led { width: 8px; diff --git a/src/features/crm/CsHome/CsHomePage.jsx b/src/features/crm/CsHome/CsHomePage.jsx index c1bd562..3810e5a 100644 --- a/src/features/crm/CsHome/CsHomePage.jsx +++ b/src/features/crm/CsHome/CsHomePage.jsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { fetchWithRefresh } from '../../../lib/api' import { crmUrl } from '../../../lib/endpoints' +import ActiveCustomerTrendLine from './ActiveCustomerTrendLine' import './CsHome.css' const POLL_MS = 5 * 60 * 1000 // 5분 — 드라이버 대시보드와 동일 @@ -38,18 +39,21 @@ const categoryOf = (code) => { export default function CsHomePage() { const [rows, setRows] = useState([]) // /driver-daily-stat 응답 (오늘) const [cust, setCust] = useState(null) // { total, today } | null + const [trend, setTrend] = useState([]) // [{ date, count }] 최근 7일 활성 고객 const [loading, setLoading] = useState(true) const [driverErr, setDriverErr] = useState(null) const [custErr, setCustErr] = useState(null) + const [trendErr, setTrendErr] = useState(null) const [updatedAt, setUpdatedAt] = useState(null) const load = useCallback(async (signal) => { const today = localToday() - // 두 소스를 독립적으로 호출 — 한쪽이 실패해도 나머지는 그대로 표시. - const [driverRes, custRes] = await Promise.allSettled([ + // 세 소스를 독립적으로 호출 — 한쪽이 실패해도 나머지는 그대로 표시. + const [driverRes, custRes, trendRes] = await Promise.allSettled([ fetchWithRefresh(crmUrl(`/driver-daily-stat?date=${today}`)), fetchWithRefresh(crmUrl('/customer/summary')), + fetchWithRefresh(crmUrl('/customer/active-trend?days=7')), ]) // 드라이버 운행 / 오일 픽업 @@ -80,6 +84,17 @@ export default function CsHomePage() { if (!signal?.aborted) setCustErr(e.message || '고객 요약을 불러오지 못했습니다.') } + // 최근 7일 활성 고객 추이 + try { + if (trendRes.status !== 'fulfilled') throw trendRes.reason + const res = trendRes.value + if (!res.ok) throw new Error(`활성 고객 추이 요청 실패 (${res.status})`) + const list = asList(await res.json()) + if (!signal?.aborted) { setTrend(list); setTrendErr(null) } + } catch (e) { + if (!signal?.aborted) setTrendErr(e.message || '활성 고객 추이를 불러오지 못했습니다.') + } + if (!signal?.aborted) { setUpdatedAt(new Date()) setLoading(false) @@ -146,8 +161,15 @@ export default function CsHomePage() { {custErr && 고객 요약 미연동 · {custErr}}
- - + + +
+
+
+ 최근 7일 활성 고객 추이 + {trendErr && 추이 데이터 오류 · {trendErr}} +
+