From e4ca0b47fa07a535d6e667712de7a44d88669c3c Mon Sep 17 00:00:00 2001 From: Hyojin Ahn Date: Wed, 17 Jun 2026 16:21:09 -0400 Subject: [PATCH] =?UTF-8?q?dashboard=20=EC=B4=88=EA=B8=B0=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.jsx | 2 + src/features/crm/DistanceStrip.jsx | 177 ++++++++++++ src/features/crm/DriverDailyStat.css | 311 +++++++++++++++++++++ src/features/crm/DriverDailyStatPage.jsx | 225 +++++++++++++++ src/features/crm/DriverHistoryLine.jsx | 189 +++++++++++++ src/features/crm/EfficiencyScatter.jsx | 337 +++++++++++++++++++++++ src/lib/endpoints.js | 6 + vite.config.js | 7 + 8 files changed, 1254 insertions(+) create mode 100644 src/features/crm/DistanceStrip.jsx create mode 100644 src/features/crm/DriverDailyStat.css create mode 100644 src/features/crm/DriverDailyStatPage.jsx create mode 100644 src/features/crm/DriverHistoryLine.jsx create mode 100644 src/features/crm/EfficiencyScatter.jsx diff --git a/src/app/App.jsx b/src/app/App.jsx index 233ea58..25665ad 100644 --- a/src/app/App.jsx +++ b/src/app/App.jsx @@ -7,6 +7,7 @@ import Login from '../auth/pages/Login' import Home from '../auth/pages/Home' import Admin from '../auth/pages/Admin' import ProductsPage from '../features/products/ProductsPage' +import DriverDailyStatPage from '../features/crm/DriverDailyStatPage' import { RequireAuth, RequireRole } from '../auth/authGuard' function AppRoutes() { @@ -33,6 +34,7 @@ function AppRoutes() { > } /> } /> + } /> {/* Admin-only route: front-end guard for UX; enforce the role on the server too. */} { + const n = parseFloat(String(s ?? '').replace(/[^0-9.]/g, '')) + return Number.isFinite(n) ? n : 0 +} +const hm = (s) => { + if (!s) return '—' + const d = new Date(s) + return isNaN(d) ? '—' : `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` +} +const statusText = (s) => (s === 'C' ? '운행 종료' : s === 'P' ? '일시정지' : '운행 중') + +// 단색(BI) 툴팁 아이콘 +const TipIcon = ({ type, x, y, c }) => { + const t = `translate(${x} ${y - 9}) scale(0.85)` + if (type === 'dist') // 이동거리(트럭) + return ( + + + + + + + + + ) + if (type === 'dur') // 운행시간(스톱워치) + return ( + + + + + + ) + if (type === 'time') + return ( + + + + + ) + return null +} + +export default function DistanceStrip({ rows }) { + const [hoveredId, setHoveredId] = useState(null) + + const model = useMemo(() => { + const items = (rows ?? []) + .filter((r) => r.ddsDriverId != null) + .map((r) => ({ + rowId: r.ddsId ?? `${r.ddsDriverId}-${r.ddsNumber}`, + driverId: r.ddsDriverId, + num: String(r.ddsNumber ?? '').replace(/[^0-9]/g, '') || String(r.ddsNumber ?? ''), + numberLabel: r.ddsNumber ?? '', + name: r.ddsMainDriver ?? '', + km: kmOf(r.ddsKm), + kmText: r.ddsKm ?? '', + minText: r.ddsMin || '—', + status: r.ddsStatus, + startAt: r.ddsStartAt, + endAt: r.ddsEndAt, + })) + .sort((a, b) => a.km - b.km) + + const kms = items.map((d) => d.km) + const xDataMin = kms.length ? Math.min(...kms) : 0 + const xDataMax = kms.length ? Math.max(...kms) : 80 + const xRange = Math.max(1, xDataMax - xDataMin) + let xMin = Math.max(0, Math.floor((xDataMin - xRange * 0.12) / 10) * 10) + let xMax = Math.ceil((xDataMax + xRange * 0.08) / 10) * 10 + if (xMax - xMin < 40) { const m = (xMin + xMax) / 2; xMin = Math.max(0, m - 20); xMax = m + 20 } + const xOf = (v) => PL + ((v - xMin) / (xMax - xMin)) * (PR - PL) + items.forEach((d) => { d.mx = xOf(d.km) }) + + // 이름 라벨 위/아래 번갈아 배치 (겹침 줄이기) + let belowMax = -Infinity, aboveMax = -Infinity + items.forEach((d) => { + const half = (d.name.length * 11 + 8) / 2 + if (d.mx - half > belowMax + 6) { d.lane = 'below'; belowMax = d.mx + half } + else if (d.mx - half > aboveMax + 6) { d.lane = 'above'; aboveMax = d.mx + half } + else { d.lane = 'below'; belowMax = d.mx + half } + }) + + const xTicks = [] + for (let i = 0; i <= 5; i++) xTicks.push(xMin + ((xMax - xMin) * i) / 5) + + return { items, xTicks, xOf } + }, [rows]) + + const { items, xTicks } = model + + if (!items.length) 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 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 ( + + + + {d.numberLabel} · {d.name} + + {rows.map((r, i) => { + const by = ty + 33 + i * lineH + return ( + + + {r.t} + + ) + })} + + ) + } + + return ( + + + + + + + + {/* 레인 베이스라인 */} + + + {/* x축 눈금 */} + {xTicks.map((v) => ( + + {Math.round(v)} + + ))} + + 이동거리 (km) + + + {/* 고스트 마커 + 이름 */} + {order.map((d) => { + const isHover = d.rowId === hoveredId + const r = isHover ? R * 1.18 : R + const ly = d.lane === 'below' ? CY + R + 13 : CY - R - 7 + return ( + setHoveredId(d.rowId)} + onMouseLeave={() => setHoveredId((id) => (id === d.rowId ? null : id))}> + + {d.num} + {d.name} + + ) + })} + + {hovered && renderTooltip(hovered)} + + ) +} diff --git a/src/features/crm/DriverDailyStat.css b/src/features/crm/DriverDailyStat.css new file mode 100644 index 0000000..4734712 --- /dev/null +++ b/src/features/crm/DriverDailyStat.css @@ -0,0 +1,311 @@ +/* Driver daily stat dashboard — co-located styles. + Mirrors the app's plain class-based CSS and green brand (#4CAF50). */ + +.dstat-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 4px; +} + +.dstat-title { + margin: 0; + font-size: 20px; +} + +.dstat-sub { + font-size: 13px; + color: #6b7280; +} + +.dstat-sub .dstat-dot { + color: #4CAF50; + margin-right: 4px; +} + +.dstat-filters { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; + margin: 16px 0; +} + +.dstat-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dstat-field label { + font-size: 12px; + color: #6b7280; +} + +.dstat-input, +.dstat-select { + height: 36px; + padding: 0 10px; + font-size: 14px; + font-family: inherit; + border: 1px solid #dcdfe3; + border-radius: 6px; + background: #fff; + color: #1f2937; +} + +.dstat-input:focus, +.dstat-select:focus { + outline: none; + border-color: #4CAF50; +} + +.dstat-btn { + height: 36px; + padding: 0 14px; + font-size: 14px; + border: 1px solid #4CAF50; + border-radius: 6px; + background: #4CAF50; + color: #fff; + cursor: pointer; +} + +.dstat-btn.ghost { + background: transparent; + color: #2e7d32; +} + +.dstat-card { + background: #fff; + border: 1px solid #eceef0; + border-radius: 10px; + padding: 16px; +} + +.dstat-card-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.dstat-card-title { + font-size: 15px; + font-weight: 600; +} + +.dstat-chart { + width: 100%; + height: auto; + display: block; +} + +/* 마커 배지: hover 시 살짝 확대 + 입체감 */ +.dstat-badge { + cursor: pointer; + transform-box: fill-box; + transform-origin: center; + transition: transform 0.12s ease; +} + +.dstat-badge:hover { + transform: scale(1.12); +} + +.dstat-empty { + padding: 40px 0; + text-align: center; + color: #9ca3af; + font-size: 14px; +} + +.dstat-stats { + display: flex; + gap: 24px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.dstat-stat .k { + font-size: 12px; + color: #6b7280; +} + +.dstat-stat .v { + font-size: 22px; + font-weight: 600; + color: #1f2937; +} + +.dstat-legend { + display: flex; + gap: 16px; + flex-wrap: wrap; + margin-top: 8px; + font-size: 12px; + color: #6b7280; + align-items: center; +} + +.dstat-legend .item { + display: inline-flex; + align-items: center; + gap: 6px; +} + +/* 픽업 전(주문 없음) 드라이버 칩 트레이 */ +.dstat-waiting { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid #eef1ef; +} + +.dstat-waiting .lbl { + font-size: 12px; + color: #8a948c; + margin-right: 4px; +} + +.dstat-chip { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 4px 12px 4px 4px; + border: 1px solid #e3e8e4; + border-radius: 999px; + background: #fff; + font-family: inherit; + font-size: 12.5px; + color: #3a463e; + cursor: pointer; +} + +.dstat-chip:hover { + border-color: #bcd2c2; + background: #f6faf7; +} + +.dstat-chip .n { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + background: #eef3ef; + color: #5b6b60; + font-size: 11px; + font-weight: 600; +} + +.dstat-msg { + padding: 24px 0; + color: #6b7280; + font-size: 14px; +} + +.dstat-msg.error { + color: #d32f2f; +} + +/* 전체 요약 */ +.dstat-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.dstat-sumcard { + background: #fff; + border: 1px solid #eceef0; + border-radius: 10px; + padding: 14px 16px; +} + +.dstat-sumcard.primary { + border-left: 3px solid #2e7d32; +} + +.dstat-sumcard .k { + font-size: 12px; + color: #6b7280; + margin-bottom: 4px; +} + +.dstat-sumcard .v { + font-size: 22px; + font-weight: 600; + color: #1f2937; + line-height: 1.2; +} + +.dstat-sumcard .v .u { + font-size: 13px; + font-weight: 500; + color: #9aa5a0; +} + +.dstat-sumcard .sub { + margin-top: 2px; + font-size: 12px; + color: #2e7d32; +} + +@media (max-width: 640px) { + .dstat-summary { + grid-template-columns: 1fr; + } +} + +/* 픽업 전 칩 */ +.dstat-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #f0f2f0; +} + +.dstat-chips-label { + font-size: 12px; + color: #9aa5a0; + margin-right: 2px; +} + +.dstat-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #5b6b60; + background: #f6f8f6; + border: 1px solid #e4eae5; + border-radius: 999px; + padding: 3px 11px 3px 3px; + cursor: default; +} + +.dstat-chip-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + border: 1.3px dashed #b5c2b8; + background: #fff; + color: #9aa5a0; + font-size: 10px; + font-weight: 600; +} \ No newline at end of file diff --git a/src/features/crm/DriverDailyStatPage.jsx b/src/features/crm/DriverDailyStatPage.jsx new file mode 100644 index 0000000..dcdcbab --- /dev/null +++ b/src/features/crm/DriverDailyStatPage.jsx @@ -0,0 +1,225 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { fetchWithRefresh } from '../../lib/api' +import { crmUrl } from '../../lib/endpoints' +import EfficiencyScatter from './EfficiencyScatter' +import DistanceStrip from './DistanceStrip' +import DriverHistoryLine from './DriverHistoryLine' +import './DriverDailyStat.css' + +const POLL_MS = 5 * 60 * 1000 // 5분 + +const localToday = () => { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} +const asList = (json) => (Array.isArray(json) ? json : (json?.data ?? [])) +const asObject = (json) => (json?.data ?? json ?? null) + +export default function DriverDailyStatPage() { + const [date, setDate] = useState(localToday()) + const [driverId, setDriverId] = useState('') // '' = 선택 안 함 + const [rows, setRows] = useState([]) + const [history, setHistory] = useState(null) + const [ovLoading, setOvLoading] = useState(true) + const [histLoading, setHistLoading] = useState(false) + const [error, setError] = useState(null) + const [updatedAt, setUpdatedAt] = useState(null) + + // 개요(전체) — 날짜에만 의존. 드라이버 선택은 여기 안 건드림. + const loadOverview = useCallback(async (signal) => { + try { + const res = await fetchWithRefresh(crmUrl(`/driver-daily-stat?date=${date}`)) + if (!res.ok) throw new Error(`개요 요청 실패 (${res.status})`) + const list = asList(await res.json()) + if (signal?.aborted) return + setRows(list) + setUpdatedAt(new Date()) + setError(null) + } catch (e) { + if (!signal?.aborted) setError(e.message) + } finally { + if (!signal?.aborted) setOvLoading(false) + } + }, [date]) + + useEffect(() => { + const ctrl = new AbortController() + setOvLoading(true) + loadOverview(ctrl.signal) + const id = setInterval(() => loadOverview(ctrl.signal), POLL_MS) + return () => { ctrl.abort(); clearInterval(id) } + }, [loadOverview]) + + // 드릴다운(단일 드라이버) — 선택됐을 때만, 아래에 따로. + const loadHistory = useCallback(async (signal) => { + if (!driverId) { setHistory(null); return } + try { + setHistLoading(true) + const res = await fetchWithRefresh(crmUrl(`/driver-daily-stat/history?driverId=${driverId}&date=${date}`)) + if (!res.ok) throw new Error(`이력 요청 실패 (${res.status})`) + const h = asObject(await res.json()) + if (signal?.aborted) return + setHistory(h) + } catch (e) { + if (!signal?.aborted) setError(e.message) + } finally { + if (!signal?.aborted) setHistLoading(false) + } + }, [date, driverId]) + + useEffect(() => { + const ctrl = new AbortController() + loadHistory(ctrl.signal) + const id = setInterval(() => loadHistory(ctrl.signal), POLL_MS) + return () => { ctrl.abort(); clearInterval(id) } + }, [loadHistory]) + + const drivers = useMemo(() => { + const seen = new Map() + for (const r of rows) { + if (r.ddsDriverId != null && !seen.has(r.ddsDriverId)) { + seen.set(r.ddsDriverId, r.ddsMainDriver ?? `드라이버 ${r.ddsDriverId}`) + } + } + return [...seen.entries()].map(([id, name]) => ({ id, name })) + }, [rows]) + + const selectedName = drivers.find((d) => String(d.id) === String(driverId))?.name + + // 픽업 있음(산점도) / 픽업 0(칩) 분리 + const activeRows = useMemo(() => rows.filter((r) => Number(r.ddsTotalQty ?? 0) > 0), [rows]) + const zeroRows = useMemo(() => rows.filter((r) => Number(r.ddsTotalQty ?? 0) <= 0), [rows]) + + // 전체 요약 (선택과 무관, 날짜 기준 전체 합) + const summary = useMemo(() => { + let plannedQty = 0, totalQty = 0, completed = 0, target = 0 + for (const r of rows) { + plannedQty += Number(r.ddsPlannedQty ?? 0) + totalQty += Number(r.ddsTotalQty ?? 0) + completed += Number(r.ddsCompletedCount ?? 0) + target += Number(r.ddsPlannedCount ?? 0) + } + const rate = plannedQty > 0 ? Math.round((totalQty / plannedQty) * 100) : null + return { + plannedQty: Math.round(plannedQty), + totalQty: Math.round(totalQty), + completed, + target, + rate, + } + }, [rows]) + + 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 ( + + + + {hm(new Date(p.t))} · {p.cust || '고객 미상'} + + {lines.map((ln, i) => ( + {ln} + ))} + + ) + } + + return ( + <> +
+
+
현재 누적 픽업량
+
{Math.round(last.cum).toLocaleString()}
+
+
+
누적 방문 완료
+
{last.done}
+
+
+ + + + + + + + + + + + + {xTicks.map((t, i) => ( + + + + {hm(new Date(t))} + + + ))} + {yTicks.map((v) => ( + + + + {Math.round(v)} + + + ))} + 누적 오일 픽업량 (L) + + + + + {hoverIdx != null && pts[hoverIdx] && ( + + )} + + {pts.map((p, i) => { + const cx = xOf(p.t), cy = yOf(p.cum) + const on = hoverIdx === i + return ( + setHoverIdx(i)} + onMouseLeave={() => setHoverIdx((idx) => (idx === i ? null : idx))}> + + + + ) + })} + + {hoverIdx != null && pts[hoverIdx] && renderTooltip(pts[hoverIdx])} + + + ) +} diff --git a/src/features/crm/EfficiencyScatter.jsx b/src/features/crm/EfficiencyScatter.jsx new file mode 100644 index 0000000..af9489d --- /dev/null +++ b/src/features/crm/EfficiencyScatter.jsx @@ -0,0 +1,337 @@ +import { useMemo, useState } from 'react' + +// 전체 드라이버 효율 산점도 (모던 리디자인). +// x = 이동거리(km), y = 오일 픽업량 +// 마커 = 번호 배지 + 진행률 링(완료/목표). 운행중=채운 배지 / 종료=옅은 배지+체크 / 픽업전=고스트 +// 호버: 해당 마커가 맨 위로 + 상세 툴팁(시작/종료 시각 포함). 클릭: onSelect(driverId) +const W = 720, H = 470, PL = 56, PR = 600, PT = 44, PB = 400, R = 11 +const ACCENT = '#2e7d32' + +const kmOf = (s) => { + const n = parseFloat(String(s ?? '').replace(/[^0-9.]/g, '')) + return Number.isFinite(n) ? n : 0 +} +const fmtHM = (s) => { + if (!s) return '—' + const d = new Date(s) + if (isNaN(d)) return '—' + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` +} + +// 단색(BI) 툴팁 아이콘. y = 텍스트 baseline 기준, 약 13px. +const TipIcon = ({ type, x, y, c }) => { + const t = `translate(${x} ${y - 9}) scale(0.85)` + if (type === 'oil') // 오일 방울 + return + if (type === 'dist') // 이동거리(트럭) + return ( + + + + + + + + + ) + if (type === 'visit') // 방문(핀) + return ( + + + + + ) + if (type === 'time') // 시간(시계) + return ( + + + + + ) + return null +} + +export default function EfficiencyScatter({ rows, onSelect, selectedId }) { + const [hoveredId, setHoveredId] = useState(null) + + const model = useMemo(() => { + const items = (rows ?? []) + .filter((r) => r.ddsDriverId != null) + .map((r) => ({ + rowId: r.ddsId ?? `${r.ddsDriverId}-${r.ddsNumber}`, // 출차 행 단위 식별자 + driverId: r.ddsDriverId, + num: String(r.ddsNumber ?? '').replace(/[^0-9]/g, '') || String(r.ddsNumber ?? ''), + numberLabel: r.ddsNumber ?? '', + name: r.ddsMainDriver ?? '', + km: kmOf(r.ddsKm), + kmText: r.ddsKm ?? '', + oil: Number(r.ddsTotalQty ?? 0), + plannedQty: Number(r.ddsPlannedQty ?? 0), + completed: Number(r.ddsCompletedCount ?? 0), + target: Number(r.ddsPlannedCount ?? 0), + running: r.ddsStatus !== 'C', // C = 운행종료 + startAt: r.ddsStartAt, + endAt: r.ddsEndAt, + })) + + // x축(이동거리)도 실제 범위로 자동 스케일 (0부터 시작 안 함) + const kms = items.map((d) => d.km) + const xDataMin = kms.length ? Math.min(...kms) : 0 + const xDataMax = kms.length ? Math.max(...kms) : 80 + const xRange = Math.max(1, xDataMax - xDataMin) + let xMin = Math.max(0, Math.floor((xDataMin - xRange * 0.12) / 10) * 10) + let xMax = Math.ceil((xDataMax + xRange * 0.08) / 10) * 10 + if (xMax - xMin < 40) { const m = (xMin + xMax) / 2; xMin = Math.max(0, m - 20); xMax = m + 20 } + + // y축은 실제 픽업 범위로 자동 스케일 (0부터 시작 안 함) + const oils = items.map((d) => d.oil) + const dataMin = oils.length ? Math.min(...oils) : 0 + const dataMax = oils.length ? Math.max(...oils) : 500 + const range = Math.max(1, dataMax - dataMin) + let yMin = Math.max(0, Math.floor((dataMin - range * 0.15) / 250) * 250) + let yMax = Math.ceil((dataMax + range * 0.1) / 250) * 250 + if (yMax - yMin < 1000) { const mid = (yMin + yMax) / 2; yMin = Math.max(0, mid - 500); yMax = mid + 500 } + + const xOf = (v) => PL + ((v - xMin) / (xMax - xMin)) * (PR - PL) + const yOf = (v) => PB - ((v - yMin) / (yMax - yMin)) * (PB - PT) + items.forEach((d) => { d.mx = xOf(d.km); d.my = yOf(d.oil) }) + + // 마커 겹침 분산 (위치는 근사값 — 정확한 값은 호버/클릭으로 확인) + const minDist = 2 * R + 6 + for (let iter = 0; iter < 80; iter++) { + let moved = false + for (let i = 0; i < items.length; i++) { + for (let j = i + 1; j < items.length; j++) { + const a = items[i], b = items[j] + let dx = a.mx - b.mx, dy = a.my - b.my + let dist = Math.hypot(dx, dy) + if (dist < 0.01) { + const ang = (i * 137.5) * Math.PI / 180 // 골든앵글: 정확히 겹치면 결정적으로 분리 + dx = Math.cos(ang); dy = Math.sin(ang); dist = 1 + } + if (dist < minDist) { + const push = (minDist - dist) / 2 + const ux = dx / dist, uy = dy / dist + a.mx += ux * push; a.my += uy * push + b.mx -= ux * push; b.my -= uy * push + moved = true + } + } + } + items.forEach((d) => { + d.mx = Math.min(Math.max(d.mx, PL + R), PR - R) + d.my = Math.min(Math.max(d.my, PT + R), PB - R) + }) + if (!moved) break + } + + // 라벨 배치: 빈 자리 우선, 없으면 '겹침이 가장 적은' 자리 선택 + const placed = [] + const gap = R + 7 + const olArea = (a, b) => { + const ix = Math.min(a.lx + a.w, b.lx + b.w) - Math.max(a.lx, b.lx) + const iy = Math.min(a.ly + a.h, b.ly + b.h) - Math.max(a.ly, b.ly) + return ix > 0 && iy > 0 ? ix * iy : 0 + } + const hitsMarker = (box, self) => + items.filter((o) => o !== self && + o.mx > box.lx - R && o.mx < box.lx + box.w + R && + o.my > box.ly - R && o.my < box.ly + box.h + R).length + + // 라벨 폭 추정: 한글이 라틴/숫자보다 넓으므로 문자별 가중 + const charW = (ch) => (/[가-힣]/.test(ch) ? 12 : /[A-Za-z]/.test(ch) ? 7 : 5.5) + const textW = (s) => [...String(s)].reduce((a, c) => a + charW(c), 0) + // 같은 높이 오른쪽/왼쪽 → 세로 오프셋 점증 (간격은 라벨 높이 넘기게) + const offsets = [0, -16, 16, -32, 32, -48, 48, -64, 64, -80, 80] + items.forEach((d) => { + const w = Math.max(textW(d.name) + 6, d.target > 0 ? 46 : 0) + const h = d.target > 0 ? 24 : 14 + let chosen = null, best = null, bestPen = Infinity + for (const dy of offsets) { + for (const side of [1, -1]) { + if (side > 0 && d.mx + gap + w > PR) continue + if (side < 0 && d.mx - gap - w < PL) continue + const box = { + lx: side > 0 ? d.mx + gap : d.mx - gap - w, + ly: d.my - h / 2 + dy, w, h, side, + } + if (box.ly < PT || box.ly + h > PB + 30) continue + let pen = 0 + for (const p of placed) pen += olArea(box, p) * 4 + pen += hitsMarker(box, d) * 360 + if (pen === 0) { chosen = box; break } + pen += Math.abs(dy) * 0.4 // 동점이면 마커에 가까운 자리 선호 + if (pen < bestPen) { bestPen = pen; best = box } + } + if (chosen) break + } + const final = chosen || best || { lx: d.mx + gap, ly: d.my - h / 2, w, h, side: 1 } + d.label = final + placed.push(final) + }) + + const xTicks = [] + for (let i = 0; i <= 5; i++) xTicks.push(xMin + ((xMax - xMin) * i) / 5) + const yTicks = [] + for (let i = 0; i <= 4; i++) yTicks.push(yMin + ((yMax - yMin) * i) / 4) + + return { items, xTicks, yTicks, yOf, xOf } + }, [rows]) + + const { items, xTicks, yTicks, yOf, xOf } = model + + if (!items.length) { + 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 ( + setHoveredId(d.rowId)} + onMouseLeave={() => setHoveredId((id) => (id === d.rowId ? null : id))} + onClick={() => onSelect?.(d.driverId)}> + + {isSel && } + + {empty ? ( + // 픽업 전 / 예상량 없음 = 빈 점선 잔 + <> + + {d.num} + + ) : ( + // 액체 잔: 안쪽이 아래에서부터 차오름 (f = 픽업/예상) + <> + + + {f > 0 && } + + {d.num} + {!d.running && ( + <> + + + + )} + + )} + + 0 ? d.label.lx : d.label.lx + d.label.w} + y={d.label.ly + 10} textAnchor={d.label.side > 0 ? 'start' : 'end'} + fontSize="10.5" fontWeight="500" fill="#28332b">{d.name} + {d.target > 0 && ( + 0 ? d.label.lx : d.label.lx + d.label.w} + y={d.label.ly + 22} textAnchor={d.label.side > 0 ? 'start' : 'end'} + fontSize="9.5" fill="#8a948c">{d.completed} / {d.target} + )} + + ) + } + + // 툴팁 (호버 마커 기준, 화면 안으로 클램프) + const renderTooltip = (d) => { + const pct = d.plannedQty > 0 ? Math.round((d.oil / d.plannedQty) * 100) : null + const rows = [ + { ic: 'oil', t: pct != null + ? `${Math.round(d.oil).toLocaleString()} / ${Math.round(d.plannedQty).toLocaleString()} L (${pct}%)` + : `${Math.round(d.oil).toLocaleString()} L` }, + { ic: 'dist', t: d.kmText || `${d.km} km` }, + { ic: 'visit', t: d.target > 0 ? `${d.completed} / ${d.target}` : '방문 목표 없음' }, + { ic: 'time', t: d.endAt ? `${fmtHM(d.startAt)} ~ ${fmtHM(d.endAt)}` : `${fmtHM(d.startAt)} ~ 운행 중` }, + ] + const lineH = 16 + const tw = 168, th = 24 + rows.length * lineH + const tx = Math.min(Math.max(d.mx + 16, 6), W - tw - 6) + const ty = Math.min(Math.max(d.my - th - 10, 6), H - th - 6) + return ( + + + + {d.numberLabel} · {d.name} + + {rows.map((r, i) => { + const by = ty + 33 + i * lineH + return ( + + + {r.t} + + ) + })} + + ) + } + + return ( + + + + + + + + {/* 격자: 가로만, 옅게 */} + {yTicks.map((v) => ( + + + {Math.round(v)} + + ))} + {xTicks.map((v) => ( + + {Math.round(v)} + + ))} + + {/* 축 제목: 오일 픽업량은 y축 옆 세로 라벨로 */} + 오일 픽업량 (L) + 이동거리 (km) + + {/* connectors */} + {items.map((d) => { + const lcy = d.label.ly + d.label.h / 2 + const near = d.label.side > 0 ? d.label.lx : d.label.lx + d.label.w + if (Math.abs(lcy - d.my) <= R + 6) return null + return ( + + ) + })} + + {/* markers (hovered last = on top) */} + {order.map(renderMarker)} + + {/* tooltip on top of everything */} + {hovered && renderTooltip(hovered)} + + ) +} diff --git a/src/lib/endpoints.js b/src/lib/endpoints.js index 2963cea..c27cd88 100644 --- a/src/lib/endpoints.js +++ b/src/lib/endpoints.js @@ -7,11 +7,17 @@ // Vite proxy forwards to the backend. In prod they should point at your gateway. const AUTH_BASE = import.meta.env.VITE_AUTH_SERVICE_URL ?? '' +const CRM_BASE = import.meta.env.VITE_CRM_API_URL ?? '' const OPR_BASE = import.meta.env.VITE_OPR_API_URL ?? '' +if (import.meta.env.DEV && !CRM_BASE) { + console.warn('[endpoints] VITE_CRM_API_URL is not set — crm/data calls have no base URL.') +} + if (import.meta.env.DEV && !OPR_BASE) { console.warn('[endpoints] VITE_OPR_API_URL is not set — opr/data calls have no base URL.') } export const authUrl = (path = '') => `${AUTH_BASE}${path}` +export const crmUrl = (path = '') => `${CRM_BASE}${path}` export const oprUrl = (path = '') => `${OPR_BASE}${path}` diff --git a/vite.config.js b/vite.config.js index 020c5ba..09722b3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,6 +2,7 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' const AUTH_BACKEND = 'http://localhost:8080' // auth-service +const CRM_BACKEND = 'http://localhost:8082' // crm-rest-api const OPR_BACKEND = 'http://localhost:8083' // opr-rest-api // https://vite.dev/config/ @@ -16,6 +17,12 @@ export default defineConfig({ changeOrigin: true, secure: false, }, + // /crm-rest-api/* -> http://localhost:8081/crm-rest-api/* + '/crm-rest-api': { + target: CRM_BACKEND, + changeOrigin: true, + secure: false, + }, // /opr-rest-api/* -> http://localhost:8083/opr-rest-api/* '/opr-rest-api': { target: OPR_BACKEND,