diff --git a/src/features/crm/CsHome/ActiveCustomerTrendLine.jsx b/src/features/crm/CsHome/ActiveCustomerTrendLine.jsx index 0aba8ec..bf74950 100644 --- a/src/features/crm/CsHome/ActiveCustomerTrendLine.jsx +++ b/src/features/crm/CsHome/ActiveCustomerTrendLine.jsx @@ -1,72 +1,121 @@ import { useMemo } from 'react' -// 최근 N일 활성 고객 수 — 미니멀 라인. -// 선 + 점 + 각 점 위 숫자 + 아래 날짜만. (격자/영역/그림자/툴팁 없음) -const W = 720, H = 180 -const X0 = 30, X1 = W - 30 // 좌우 여백 -const YTOP = 52, YBOT = 132 // 선이 그려지는 세로 밴드 -const ACCENT = '#2e7d32' +// 최근 N일: 위 = 활성 고객 수(선), 아래 = 일별 신규(+)/해지(−) 막대. x축(날짜) 공유. +// 절대 수와 일별 증감은 스케일이 완전히 달라서 한 영역에 겹치지 않고 위/아래로 분리. +const W = 720, H = 320 +const X0 = 40, X1 = W - 28 +const L_TOP = 44, L_BOT = 118 // 위: 선 영역 +const B_TOP = 182, B_BOT = 268 // 아래: 막대 영역 +const DATE_Y = H - 10 +const ACCENT = '#2e7d32' // 선 / 신규(+) +const REM = '#d64545' // 해지(−) const parseYmd = (s) => { - const [y, m, d] = String(s).split('-').map(Number) + const [, 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)) + const pts = (points ?? []).map((p) => ({ + ...parseYmd(p.date), + count: Number(p.count ?? 0), + added: Number(p.added ?? 0), + removed: Number(p.removed ?? 0), + })) 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)}`) + // 위: 선 스케일 (절대 수 — 변화가 작으니 데이터 범위에 맞춰 확대) + const cs = pts.map((p) => p.count) + let lo = Math.min(...cs), hi = Math.max(...cs) + const pad = lo === hi ? Math.max(1, Math.round(Math.abs(hi) * 0.02)) : (hi - lo) * 0.6 + lo -= pad; hi += pad + if (lo === hi) hi = lo + 1 + const yLine = (v) => L_BOT - ((v - lo) / (hi - lo)) * (L_BOT - L_TOP) + const linePath = pts + .map((p, i) => `${i === 0 ? 'M' : 'L'} ${xOf(i).toFixed(2)},${yLine(p.count).toFixed(2)}`) .join(' ') - return { pts, xOf, yOf, linePath } + // 아래: 막대 스케일 (그 주 증감 최대치 기준 단일 스케일) + const maxUp = Math.max(0, ...pts.map((p) => p.added)) + const maxDown = Math.max(0, ...pts.map((p) => p.removed)) + const denom = maxUp + maxDown + const ppu = denom > 0 ? (B_BOT - B_TOP) / denom : 0 + const baseline = denom > 0 ? B_TOP + maxUp * ppu : (B_TOP + B_BOT) / 2 + const spacing = n > 1 ? (X1 - X0) / (n - 1) : (X1 - X0) + const bw = Math.min(26, Math.max(12, spacing * 0.42)) + + return { pts, xOf, yLine, linePath, lastIdx: n - 1, ppu, baseline, bw, hasBars: denom > 0 } }, [points]) if (!model.pts.length) { - return
최근 활성 고객 데이터가 없습니다.
+ return
최근 데이터가 없습니다.
} - const { pts, xOf, yOf, linePath } = model - const lastIdx = pts.length - 1 + const { pts, xOf, yLine, linePath, lastIdx, ppu, baseline, bw, hasBars } = model return ( - + + {/* 영역 라벨 */} + 활성 고객 (곳) + 일별 증감 + + {/* 위: 활성 수 선 */} - {pts.map((p, i) => { - const cx = xOf(i), cy = yOf(p.count) - const isLast = i === lastIdx + const cx = xOf(i), cy = yLine(p.count), isLast = i === lastIdx return ( - + - - {p.count.toLocaleString()} - - + {p.count.toLocaleString()} + + ) + })} + + {/* 아래: 0 기준선 + 신규/해지 막대 */} + + {pts.map((p, i) => { + const cx = xOf(i) + const upH = p.added * ppu, downH = p.removed * ppu + const none = p.added === 0 && p.removed === 0 + return ( + + {p.added > 0 && ( + <> + + +{p.added.toLocaleString()} + + )} + {p.removed > 0 && ( + <> + + −{p.removed.toLocaleString()} + + )} + {none && ( + 0 + )} + {p.m}/{p.d} ) })} + + {!hasBars && ( + + 이 기간 변동 없음 + + )} ) } diff --git a/src/features/crm/CsHome/CsHomePage.jsx b/src/features/crm/CsHome/CsHomePage.jsx index 3810e5a..0b20c35 100644 --- a/src/features/crm/CsHome/CsHomePage.jsx +++ b/src/features/crm/CsHome/CsHomePage.jsx @@ -13,7 +13,6 @@ const localToday = () => { } const asList = (json) => (Array.isArray(json) ? json : (json?.data ?? [])) -const asObject = (json) => (json?.data ?? json ?? null) /* ───────────────────────────────────────────────────────────── 드라이버 상태 코드(ddsStatus) → 카테고리 매핑. @@ -38,21 +37,18 @@ 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 [trend, setTrend] = useState([]) // [{ date, count, added, removed }] 최근 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, trendRes] = await Promise.allSettled([ + // 두 소스를 독립적으로 호출 — 한쪽이 실패해도 나머지는 그대로 표시. + const [driverRes, trendRes] = await Promise.allSettled([ fetchWithRefresh(crmUrl(`/driver-daily-stat?date=${today}`)), - fetchWithRefresh(crmUrl('/customer/summary')), fetchWithRefresh(crmUrl('/customer/active-trend?days=7')), ]) @@ -67,23 +63,6 @@ export default function CsHomePage() { if (!signal?.aborted) setDriverErr(e.message || '운행 현황을 불러오지 못했습니다.') } - // 고객 요약 - try { - if (custRes.status !== 'fulfilled') throw custRes.reason - const res = custRes.value - if (!res.ok) throw new Error(`고객 요약 요청 실패 (${res.status})`) - const obj = asObject(await res.json()) ?? {} - // 키 이름 변형을 관대하게 수용 - const total = obj.totalCount ?? obj.total ?? obj.customerCount ?? null - const todayN = obj.todayCount ?? obj.today ?? obj.addedToday ?? obj.newToday ?? null - if (!signal?.aborted) { - setCust({ total, today: todayN }) - setCustErr(null) - } - } catch (e) { - if (!signal?.aborted) setCustErr(e.message || '고객 요약을 불러오지 못했습니다.') - } - // 최근 7일 활성 고객 추이 try { if (trendRes.status !== 'fulfilled') throw trendRes.reason @@ -158,15 +137,10 @@ export default function CsHomePage() {
고객 - {custErr && 고객 요약 미연동 · {custErr}} -
-
- -
- 최근 7일 활성 고객 추이 + 최근 7일 활성 추이 · 신규(+)/해지(−) {trendErr && 추이 데이터 오류 · {trendErr}}