고객 수 추이 추가
This commit is contained in:
parent
d71cec4709
commit
1d6d964fec
|
|
@ -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 <div className="cshome-chart-empty">최근 활성 고객 데이터가 없습니다.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pts, xOf, yOf, linePath } = model
|
||||||
|
const lastIdx = pts.length - 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg className="cshome-chart" viewBox={`0 0 ${W} ${H}`} role="img" aria-label="최근 활성 고객 추이">
|
||||||
|
<path d={linePath} fill="none" stroke={ACCENT} strokeWidth="2"
|
||||||
|
strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
|
||||||
|
{pts.map((p, i) => {
|
||||||
|
const cx = xOf(i), cy = yOf(p.count)
|
||||||
|
const isLast = i === lastIdx
|
||||||
|
return (
|
||||||
|
<g key={i}>
|
||||||
|
<circle cx={cx} cy={cy} r={isLast ? 4 : 3} fill={ACCENT} />
|
||||||
|
<text x={cx} y={cy - 10} textAnchor="middle"
|
||||||
|
fontSize={isLast ? 11 : 10} fontWeight={isLast ? 700 : 500}
|
||||||
|
fill={isLast ? ACCENT : '#5b6b60'}>
|
||||||
|
{p.count.toLocaleString()}
|
||||||
|
</text>
|
||||||
|
<text x={cx} y={H - 10} textAnchor="middle" fontSize="9.5" fill="#aab2ac">
|
||||||
|
{p.m}/{p.d}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -111,6 +111,43 @@
|
||||||
color: #2e7d32;
|
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 */
|
/* 드라이버 상태 LED */
|
||||||
.cshome-led {
|
.cshome-led {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { fetchWithRefresh } from '../../../lib/api'
|
import { fetchWithRefresh } from '../../../lib/api'
|
||||||
import { crmUrl } from '../../../lib/endpoints'
|
import { crmUrl } from '../../../lib/endpoints'
|
||||||
|
import ActiveCustomerTrendLine from './ActiveCustomerTrendLine'
|
||||||
import './CsHome.css'
|
import './CsHome.css'
|
||||||
|
|
||||||
const POLL_MS = 5 * 60 * 1000 // 5분 — 드라이버 대시보드와 동일
|
const POLL_MS = 5 * 60 * 1000 // 5분 — 드라이버 대시보드와 동일
|
||||||
|
|
@ -38,18 +39,21 @@ 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 [cust, setCust] = useState(null) // { total, today } | null
|
||||||
|
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 [custErr, setCustErr] = 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] = await Promise.allSettled([
|
const [driverRes, custRes, 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/summary')),
|
||||||
|
fetchWithRefresh(crmUrl('/customer/active-trend?days=7')),
|
||||||
])
|
])
|
||||||
|
|
||||||
// 드라이버 운행 / 오일 픽업
|
// 드라이버 운행 / 오일 픽업
|
||||||
|
|
@ -80,6 +84,17 @@ export default function CsHomePage() {
|
||||||
if (!signal?.aborted) setCustErr(e.message || '고객 요약을 불러오지 못했습니다.')
|
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) {
|
if (!signal?.aborted) {
|
||||||
setUpdatedAt(new Date())
|
setUpdatedAt(new Date())
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|
@ -146,8 +161,15 @@ export default function CsHomePage() {
|
||||||
{custErr && <span className="cshome-section-note">고객 요약 미연동 · {custErr}</span>}
|
{custErr && <span className="cshome-section-note">고객 요약 미연동 · {custErr}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="cshome-grid cshome-grid-2">
|
<div className="cshome-grid cshome-grid-2">
|
||||||
<Card label="전체 고객 수" value={fmt(cust?.total)} unit="곳" />
|
<Card label="전체 고객 수" value={fmt(cust?.total)} unit="명" />
|
||||||
<Card label="오늘 추가된 고객" value={fmt(cust?.today)} unit="곳" accent />
|
<Card label="오늘 추가된 고객" value={fmt(cust?.today)} unit="명" accent />
|
||||||
|
</div>
|
||||||
|
<div className="cshome-chart-card">
|
||||||
|
<div className="cshome-chart-head">
|
||||||
|
<span className="cshome-chart-title">최근 7일 활성 고객 추이</span>
|
||||||
|
{trendErr && <span className="cshome-section-note">추이 데이터 오류 · {trendErr}</span>}
|
||||||
|
</div>
|
||||||
|
<ActiveCustomerTrendLine points={trend} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue