해당 드라이버가 없습니다.
+
+ const order = hoveredId == null
+ ? items
+ : [...items.filter((d) => d.rowId !== hoveredId), items.find((d) => d.rowId === hoveredId)].filter(Boolean)
+ const hovered = items.find((d) => d.rowId === hoveredId)
+
+ const renderTooltip = (d) => {
+ const rows = [
+ { ic: 'dist', t: d.kmText || `${d.km} km` },
+ { ic: 'dur', t: d.minText },
+ { ic: 'time', t: d.endAt ? `${hm(d.startAt)} ~ ${hm(d.endAt)}` : `${hm(d.startAt)} ~ ${statusText(d.status)}` },
+ ]
+ const lineH = 16
+ const tw = 176, th = 24 + rows.length * lineH
+ const tx = Math.min(Math.max(d.mx - tw / 2, 6), W - tw - 6)
+ const ty = Math.max(6, CY - R - th - 8)
+ return (
+
+
+
드라이버 운행 현황
+
+ ●
+ 5분마다 자동 갱신
+ {updatedAt && ` · 업데이트 ${updatedAt.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}`}
+ {ovLoading && rows.length > 0 && ' · 갱신 중…'}
+
+
+
+
+
+
+ setDate(e.target.value)} />
+
+
+
+
+
+
+
+ {error &&
불러오기 오류: {error}
}
+ {ovLoading && rows.length === 0 &&
불러오는 중…
}
+
+ {/* 전체 요약 */}
+ {rows.length > 0 && (
+
+
+
전체 예상량
+
{summary.plannedQty.toLocaleString()} L
+
+
+
전체 픽업량
+
{summary.totalQty.toLocaleString()} L
+ {summary.rate != null &&
예상 대비 {summary.rate}%
}
+
+
+
전체 방문 수
+
+ {summary.completed.toLocaleString()} / {summary.target.toLocaleString()}
+
+
+
+ )}
+
+ {/* 전체 개요 — 선택과 무관하게 항상 유지 */}
+ {(!ovLoading || rows.length > 0) && (
+
+
+ 전체 드라이버 — 거리 대비 픽업량
+
+
setDriverId(String(id))}
+ />
+
+
+ 운행 중
+
+
+ 운행 종료
+
+
채움 = 예상 대비 픽업량 · 마커 클릭 → 아래에 누적 차트
+
+
+ )}
+
+ {/* 기타 운행 (오일 픽업 없음) — 거리 스트립 */}
+ {zeroRows.length > 0 && (
+
+
+ 기타 운행 (오일 픽업 없음) — 이동거리
+
+
+
+ )}
+
+ {/* 단일 드라이버 드릴다운 — 아래에 추가로 등장 */}
+ {driverId && (
+
+
+
+ {selectedName} · 시간대별 누적{histLoading && ' · 불러오는 중…'}
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/features/crm/DriverHistoryLine.jsx b/src/features/crm/DriverHistoryLine.jsx
new file mode 100644
index 0000000..525c59e
--- /dev/null
+++ b/src/features/crm/DriverHistoryLine.jsx
@@ -0,0 +1,189 @@
+import { useMemo, useState } from 'react'
+
+// 단일 드라이버 누적 라인.
+// x = 벽시계 시각(픽업 시각), y = 누적 픽업량
+// 각 점 호버 시 고객명 · 이번 픽업량 · 누적 툴팁
+const W = 720, H = 360, PL = 56, PR = 690, PT = 24, PB = 300
+const ACCENT = '#2e7d32'
+
+const niceMax = (v, step) => Math.max(step, Math.ceil((v || 0) / step) * step)
+const hm = (d) => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
+
+// 단조(monotone) 큐빅 보간 — 부드러우면서 누적값이 절대 내려가지 않음(오버슈트/dip 없음).
+// 누적 그래프는 항상 우상향이어야 하므로 Catmull-Rom 대신 이 방식 사용.
+function smoothPath(xy) {
+ const n = xy.length
+ if (n === 0) return ''
+ if (n === 1) return `M ${xy[0][0].toFixed(2)},${xy[0][1].toFixed(2)}`
+ const xs = xy.map((p) => p[0]), ys = xy.map((p) => p[1])
+ const dx = [], slope = []
+ for (let i = 0; i < n - 1; i++) {
+ const h = xs[i + 1] - xs[i]
+ dx.push(h)
+ slope.push(h === 0 ? 0 : (ys[i + 1] - ys[i]) / h)
+ }
+ const m = new Array(n)
+ m[0] = slope[0]
+ m[n - 1] = slope[n - 2]
+ for (let i = 1; i < n - 1; i++) {
+ m[i] = slope[i - 1] * slope[i] <= 0 ? 0 : (slope[i - 1] + slope[i]) / 2
+ }
+ // Fritsch–Carlson: 단조성 보장 (control point 기울기 제한)
+ for (let i = 0; i < n - 1; i++) {
+ if (slope[i] === 0) { m[i] = 0; m[i + 1] = 0; continue }
+ const a = m[i] / slope[i], b = m[i + 1] / slope[i], s = a * a + b * b
+ if (s > 9) {
+ const tau = 3 / Math.sqrt(s)
+ m[i] = tau * a * slope[i]
+ m[i + 1] = tau * b * slope[i]
+ }
+ }
+ let d = `M ${xs[0].toFixed(2)},${ys[0].toFixed(2)}`
+ for (let i = 0; i < n - 1; i++) {
+ const h = dx[i]
+ d += ` C ${(xs[i] + h / 3).toFixed(2)},${(ys[i] + (m[i] * h) / 3).toFixed(2)}`
+ + ` ${(xs[i + 1] - h / 3).toFixed(2)},${(ys[i + 1] - (m[i + 1] * h) / 3).toFixed(2)}`
+ + ` ${xs[i + 1].toFixed(2)},${ys[i + 1].toFixed(2)}`
+ }
+ return d
+}
+
+export default function DriverHistoryLine({ history }) {
+ const [hoverIdx, setHoverIdx] = useState(null)
+
+ const model = useMemo(() => {
+ const pts = (history?.points ?? [])
+ .map((p) => ({
+ t: new Date(p.t).getTime(),
+ cum: Number(p.cumQty ?? 0),
+ qty: Number(p.qty ?? 0),
+ done: Number(p.cumCompleted ?? 0),
+ cust: p.customerName ?? null,
+ }))
+ .filter((p) => Number.isFinite(p.t))
+ .sort((a, b) => a.t - b.t)
+
+ if (!pts.length) return { pts: [] }
+
+ let tMin = pts[0].t, tMax = pts[pts.length - 1].t
+ if (tMin === tMax) { tMin -= 30 * 60000; tMax += 30 * 60000 } // 점 1개 패딩
+ const qMax = niceMax(Math.max(0, ...pts.map((p) => p.cum)), 500)
+
+ const xOf = (t) => PL + ((t - tMin) / (tMax - tMin)) * (PR - PL)
+ const yOf = (q) => PB - (q / qMax) * (PB - PT)
+
+ const xTicks = []
+ for (let i = 0; i <= 5; i++) xTicks.push(tMin + ((tMax - tMin) * i) / 5)
+ const yTicks = []
+ for (let v = 0; v <= qMax; v += qMax / 4) yTicks.push(v)
+
+ const xy = pts.map((p) => [xOf(p.t), yOf(p.cum)])
+ const linePath = smoothPath(xy)
+ const x0 = xOf(pts[0].t), xN = xOf(pts[pts.length - 1].t)
+ const areaPath = `${linePath} L ${xN.toFixed(2)},${PB} L ${x0.toFixed(2)},${PB} Z`
+ const last = pts[pts.length - 1]
+
+ return { pts, xOf, yOf, qMax, xTicks, yTicks, linePath, areaPath, last }
+ }, [history])
+
+ if (!model.pts.length) {
+ return 선택한 날짜에 픽업 이력이 없습니다.
+ }
+
+ const { pts, xOf, yOf, xTicks, yTicks, linePath, areaPath, last } = model
+
+ const renderTooltip = (p) => {
+ const lines = [
+ `+${Math.round(p.qty).toLocaleString()} L (이번 픽업)`,
+ `누적 ${Math.round(p.cum).toLocaleString()} L`,
+ ]
+ const tw = 188, th = 22 + lines.length * 14
+ const px = xOf(p.t), py = yOf(p.cum)
+ const tx = Math.min(Math.max(px + 12, 6), W - tw - 6)
+ const ty = Math.min(Math.max(py - th - 12, 6), H - th - 6)
+ return (
+ 픽업이 기록된 드라이버가 없습니다.
+ }
+
+ // 호버된 마커를 맨 마지막에 그려서 위로 올림 (행 단위 식별)
+ const order = hoveredId == null
+ ? items
+ : [...items.filter((d) => d.rowId !== hoveredId), items.find((d) => d.rowId === hoveredId)].filter(Boolean)
+
+ const hovered = items.find((d) => d.rowId === hoveredId)
+
+ const renderMarker = (d) => {
+ const isHover = d.rowId === hoveredId
+ const isSel = selectedId != null && String(d.driverId) === String(selectedId)
+ const k = isHover ? 1.18 : 1
+ const r = R * k
+ const cx = d.mx, cy = d.my
+ const empty = d.oil <= 0 // 픽업 없음 (산점도엔 거의 안 옴, 안전장치)
+ const f = d.plannedQty > 0 ? Math.min(1, d.oil / d.plannedQty) : 1 // 픽업량 / 예상량 (예상 없으면 가득)
+ const clipId = `dstatLiq${d.rowId}`
+
+ // 액체 표면 (가벼운 물결) + 바닥. f=0/1 이면 평평.
+ const yTop = cy + r - 2 * r * f
+ const amp = f > 0 && f < 1 ? Math.max(1, r * 0.13) : 0
+ const liquid = `M ${(cx - r).toFixed(1)} ${yTop.toFixed(1)}`
+ + ` C ${(cx - r * 0.5).toFixed(1)} ${(yTop - amp).toFixed(1)}, ${(cx - r * 0.5).toFixed(1)} ${(yTop + amp).toFixed(1)}, ${cx.toFixed(1)} ${yTop.toFixed(1)}`
+ + ` S ${(cx + r * 0.5).toFixed(1)} ${(yTop + amp).toFixed(1)}, ${(cx + r).toFixed(1)} ${yTop.toFixed(1)}`
+ + ` L ${(cx + r).toFixed(1)} ${(cy + r).toFixed(1)} L ${(cx - r).toFixed(1)} ${(cy + r).toFixed(1)} Z`
+
+ return (
+