고객 수 추이에 +- 추가.

This commit is contained in:
Hyojin Ahn 2026-06-24 10:04:06 -04:00
parent 1d6d964fec
commit 39a6b35557
2 changed files with 89 additions and 66 deletions

View File

@ -1,72 +1,121 @@
import { useMemo } from 'react' import { useMemo } from 'react'
// N . // N: = (), = (+)/() . x() .
// + + + . (/// ) // / .
const W = 720, H = 180 const W = 720, H = 320
const X0 = 30, X1 = W - 30 // const X0 = 40, X1 = W - 28
const YTOP = 52, YBOT = 132 // const L_TOP = 44, L_BOT = 118 // :
const ACCENT = '#2e7d32' const B_TOP = 182, B_BOT = 268 // :
const DATE_Y = H - 10
const ACCENT = '#2e7d32' // 선 / 신규(+)
const REM = '#d64545' // ()
const parseYmd = (s) => { const parseYmd = (s) => {
const [y, m, d] = String(s).split('-').map(Number) const [, m, d] = String(s).split('-').map(Number)
return { m, d } return { m, d }
} }
export default function ActiveCustomerTrendLine({ points }) { export default function ActiveCustomerTrendLine({ points }) {
const model = useMemo(() => { const model = useMemo(() => {
const pts = (points ?? []) const pts = (points ?? []).map((p) => ({
.map((p) => ({ ...parseYmd(p.date), count: Number(p.count ?? 0) })) ...parseYmd(p.date),
.filter((p) => Number.isFinite(p.count)) count: Number(p.count ?? 0),
added: Number(p.added ?? 0),
removed: Number(p.removed ?? 0),
}))
if (!pts.length) return { pts: [] } 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 n = pts.length
const xOf = (i) => (n === 1 ? (X0 + X1) / 2 : X0 + ((X1 - X0) * i) / (n - 1)) 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 const cs = pts.map((p) => p.count)
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p[0].toFixed(2)},${p[1].toFixed(2)}`) 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(' ') .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]) }, [points])
if (!model.pts.length) { if (!model.pts.length) {
return <div className="cshome-chart-empty">최근 활성 고객 데이터가 없습니다.</div> return <div className="cshome-chart-empty">최근 데이터가 없습니다.</div>
} }
const { pts, xOf, yOf, linePath } = model const { pts, xOf, yLine, linePath, lastIdx, ppu, baseline, bw, hasBars } = model
const lastIdx = pts.length - 1
return ( return (
<svg className="cshome-chart" viewBox={`0 0 ${W} ${H}`} role="img" aria-label="최근 활성 고객 추이"> <svg className="cshome-chart" viewBox={`0 0 ${W} ${H}`} role="img"
aria-label="활성 고객 추이 및 일별 신규·해지">
{/* 영역 라벨 */}
<text x={8} y={16} fontSize="9.5" fill="#9aa5a0">활성 고객 ()</text>
<text x={8} y={B_TOP - 16} fontSize="9.5" fill="#9aa5a0">일별 증감</text>
{/* 위: 활성 수 선 */}
<path d={linePath} fill="none" stroke={ACCENT} strokeWidth="2" <path d={linePath} fill="none" stroke={ACCENT} strokeWidth="2"
strokeLinejoin="round" strokeLinecap="round" /> strokeLinejoin="round" strokeLinecap="round" />
{pts.map((p, i) => { {pts.map((p, i) => {
const cx = xOf(i), cy = yOf(p.count) const cx = xOf(i), cy = yLine(p.count), isLast = i === lastIdx
const isLast = i === lastIdx
return ( return (
<g key={i}> <g key={`l${i}`}>
<circle cx={cx} cy={cy} r={isLast ? 4 : 3} fill={ACCENT} /> <circle cx={cx} cy={cy} r={isLast ? 4 : 3} fill={ACCENT} />
<text x={cx} y={cy - 10} textAnchor="middle" <text x={cx} y={cy - 9} textAnchor="middle"
fontSize={isLast ? 11 : 10} fontWeight={isLast ? 700 : 500} fontSize={isLast ? 10.5 : 9.5} fontWeight={isLast ? 700 : 500}
fill={isLast ? ACCENT : '#5b6b60'}> fill={isLast ? ACCENT : '#5b6b60'}>{p.count.toLocaleString()}</text>
{p.count.toLocaleString()} </g>
</text> )
<text x={cx} y={H - 10} textAnchor="middle" fontSize="9.5" fill="#aab2ac"> })}
{/* 아래: 0 기준선 + 신규/해지 막대 */}
<line x1={X0} y1={baseline} x2={X1} y2={baseline} stroke="#d8e0db" />
{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 (
<g key={`b${i}`}>
{p.added > 0 && (
<>
<rect x={cx - bw / 2} y={baseline - upH} width={bw} height={upH} rx="3" fill={ACCENT} />
<text x={cx} y={baseline - upH - 6} textAnchor="middle"
fontSize="11" fontWeight="700" fill={ACCENT}>+{p.added.toLocaleString()}</text>
</>
)}
{p.removed > 0 && (
<>
<rect x={cx - bw / 2} y={baseline} width={bw} height={downH} rx="3" fill={REM} />
<text x={cx} y={baseline + downH + 14} textAnchor="middle"
fontSize="11" fontWeight="700" fill={REM}>{p.removed.toLocaleString()}</text>
</>
)}
{none && (
<text x={cx} y={baseline - 6} textAnchor="middle" fontSize="10" fill="#c4ccc6">0</text>
)}
<text x={cx} y={DATE_Y} textAnchor="middle" fontSize="9.5" fill="#aab2ac">
{p.m}/{p.d} {p.m}/{p.d}
</text> </text>
</g> </g>
) )
})} })}
{!hasBars && (
<text x={W / 2} y={baseline - 12} textAnchor="middle" fontSize="11" fill="#9aa5a0">
기간 변동 없음
</text>
)}
</svg> </svg>
) )
} }

View File

@ -13,7 +13,6 @@ const localToday = () => {
} }
const asList = (json) => (Array.isArray(json) ? json : (json?.data ?? [])) const asList = (json) => (Array.isArray(json) ? json : (json?.data ?? []))
const asObject = (json) => (json?.data ?? json ?? null)
/* /*
드라이버 상태 코드(ddsStatus) 카테고리 매핑. 드라이버 상태 코드(ddsStatus) 카테고리 매핑.
@ -38,21 +37,18 @@ const categoryOf = (code) => {
export default function CsHomePage() { export default function CsHomePage() {
const [rows, setRows] = useState([]) // /driver-daily-stat () const [rows, setRows] = useState([]) // /driver-daily-stat ()
const [cust, setCust] = useState(null) // { total, today } | null const [trend, setTrend] = useState([]) // [{ date, count, added, removed }] 7
const [trend, setTrend] = useState([]) // [{ date, count }] 7
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [driverErr, setDriverErr] = useState(null) const [driverErr, setDriverErr] = useState(null)
const [custErr, setCustErr] = useState(null)
const [trendErr, setTrendErr] = useState(null) const [trendErr, setTrendErr] = useState(null)
const [updatedAt, setUpdatedAt] = useState(null) const [updatedAt, setUpdatedAt] = useState(null)
const load = useCallback(async (signal) => { const load = useCallback(async (signal) => {
const today = localToday() 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(`/driver-daily-stat?date=${today}`)),
fetchWithRefresh(crmUrl('/customer/summary')),
fetchWithRefresh(crmUrl('/customer/active-trend?days=7')), fetchWithRefresh(crmUrl('/customer/active-trend?days=7')),
]) ])
@ -67,23 +63,6 @@ export default function CsHomePage() {
if (!signal?.aborted) setDriverErr(e.message || '운행 현황을 불러오지 못했습니다.') 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 // 7
try { try {
if (trendRes.status !== 'fulfilled') throw trendRes.reason if (trendRes.status !== 'fulfilled') throw trendRes.reason
@ -158,15 +137,10 @@ export default function CsHomePage() {
<div className="cshome-section"> <div className="cshome-section">
<div className="cshome-section-head"> <div className="cshome-section-head">
<span className="cshome-section-title">고객</span> <span className="cshome-section-title">고객</span>
{custErr && <span className="cshome-section-note">고객 요약 미연동 · {custErr}</span>}
</div>
<div className="cshome-grid cshome-grid-2">
<Card label="전체 고객 수" value={fmt(cust?.total)} unit="명" />
<Card label="오늘 추가된 고객" value={fmt(cust?.today)} unit="명" accent />
</div> </div>
<div className="cshome-chart-card"> <div className="cshome-chart-card">
<div className="cshome-chart-head"> <div className="cshome-chart-head">
<span className="cshome-chart-title">최근 7 활성 고객 추이</span> <span className="cshome-chart-title">최근 7 활성 추이 · 신규(+)/해지()</span>
{trendErr && <span className="cshome-section-note">추이 데이터 오류 · {trendErr}</span>} {trendErr && <span className="cshome-section-note">추이 데이터 오류 · {trendErr}</span>}
</div> </div>
<ActiveCustomerTrendLine points={trend} /> <ActiveCustomerTrendLine points={trend} />