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 (
+
+ )
+}
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}}
+
+