dashboard 초기버전 추가

This commit is contained in:
Hyojin Ahn 2026-06-17 16:21:09 -04:00
parent bfaa627fdf
commit e4ca0b47fa
8 changed files with 1254 additions and 0 deletions

View File

@ -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() {
>
<Route index element={<Home />} />
<Route path="products" element={<ProductsPage />} />
<Route path="crm/driver-daily-stat" element={<DriverDailyStatPage />} />
{/* Admin-only route: front-end guard for UX; enforce the role on the server too. */}
<Route

View File

@ -0,0 +1,177 @@
import { useMemo, useState } from 'react'
// ( 0) .
// x = (km), y ( ). .
const W = 720, H = 152, PL = 24, PR = 696, R = 11
const CY = 56
const kmOf = (s) => {
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 (
<g transform={t} fill={c}>
<path d="M0.6 2.4 H7.4 V7.6 H0.6 Z" />
<path d="M7.4 4.2 H9.8 V5.8 H12.6 V7.6 H7.4 Z" />
<circle cx="3" cy="8.6" r="1.5" />
<circle cx="9.6" cy="8.6" r="1.5" />
<circle cx="3" cy="8.6" r="0.6" fill="#fff" />
<circle cx="9.6" cy="8.6" r="0.6" fill="#fff" />
</g>
)
if (type === 'dur') // ()
return (
<g transform={t} stroke={c} strokeWidth="1.3" fill="none" strokeLinecap="round">
<line x1="6.5" y1="0.8" x2="6.5" y2="2.6" />
<circle cx="6.5" cy="8" r="5" />
<path d="M6.5 8 V4.9" />
</g>
)
if (type === 'time')
return (
<g transform={t} stroke={c} strokeWidth="1.3" fill="none" strokeLinecap="round">
<circle cx="6.5" cy="6.8" r="5.4" />
<path d="M6.5 6.8 V3.7 M6.5 6.8 L9.2 6.8" />
</g>
)
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 <div className="dstat-empty">해당 드라이버가 없습니다.</div>
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 (
<g pointerEvents="none">
<rect x={tx} y={ty} width={tw} height={th} rx="8"
fill="#ffffff" stroke="#e2e8e4" strokeWidth="1" filter="url(#dstatStripShadow)" />
<text x={tx + 12} y={ty + 17} fontSize="10.5" fontWeight="700" fill="#1f2937">
{d.numberLabel} · {d.name}
</text>
{rows.map((r, i) => {
const by = ty + 33 + i * lineH
return (
<g key={i}>
<TipIcon type={r.ic} x={tx + 12} y={by} c="#2e7d32" />
<text x={tx + 30} y={by} fontSize="9.5" fill="#5b6b60">{r.t}</text>
</g>
)
})}
</g>
)
}
return (
<svg className="dstat-chart" viewBox={`0 0 ${W} ${H}`} role="img"
aria-label="기타 운행 드라이버 이동거리">
<defs>
<filter id="dstatStripShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="1.5" stdDeviation="2.5" floodColor="#0b3d1f" floodOpacity="0.18" />
</filter>
</defs>
{/* 레인 베이스라인 */}
<line x1={PL} y1={CY} x2={PR} y2={CY} stroke="#e6ebe7" strokeWidth="1" />
{/* x축 눈금 */}
{xTicks.map((v) => (
<text key={v} x={model.xOf(v)} y={H - 18} textAnchor="middle" fontSize="9.5" fill="#aab2ac">
{Math.round(v)}
</text>
))}
<text x={(PL + PR) / 2} y={H - 2} textAnchor="middle" fontSize="10.5" fontWeight="600" fill="#5b6b60">
이동거리 (km)
</text>
{/* 고스트 마커 + 이름 */}
{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 (
<g key={d.rowId} className="dstat-marker"
onMouseEnter={() => setHoveredId(d.rowId)}
onMouseLeave={() => setHoveredId((id) => (id === d.rowId ? null : id))}>
<circle cx={d.mx} cy={CY} r={r} fill="#ffffff" stroke="#b5c2b8" strokeWidth="1.5" strokeDasharray="2.5 2" />
<text x={d.mx} y={CY + 4} textAnchor="middle" fontSize="10" fontWeight="600" fill="#9aa5a0">{d.num}</text>
<text x={d.mx} y={ly} textAnchor="middle" fontSize="10.5" fontWeight="500" fill="#5b6b60">{d.name}</text>
</g>
)
})}
{hovered && renderTooltip(hovered)}
</svg>
)
}

View File

@ -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;
}

View File

@ -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 (
<div>
<div className="dstat-head">
<h2 className="dstat-title">드라이버 운행 현황</h2>
<span className="dstat-sub">
<span className="dstat-dot"></span>
5분마다 자동 갱신
{updatedAt && ` · 업데이트 ${updatedAt.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}`}
{ovLoading && rows.length > 0 && ' · 갱신 중…'}
</span>
</div>
<div className="dstat-filters">
<div className="dstat-field">
<label htmlFor="dstat-date">날짜</label>
<input id="dstat-date" type="date" className="dstat-input"
value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<div className="dstat-field">
<label htmlFor="dstat-driver">드라이버</label>
<select id="dstat-driver" className="dstat-select"
value={driverId} onChange={(e) => setDriverId(e.target.value)}>
<option value="">선택 </option>
{drivers.map((d) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
</div>
{error && <div className="dstat-msg error">불러오기 오류: {error}</div>}
{ovLoading && rows.length === 0 && <div className="dstat-msg">불러오는 </div>}
{/* 전체 요약 */}
{rows.length > 0 && (
<div className="dstat-summary">
<div className="dstat-sumcard">
<div className="k">전체 예상량</div>
<div className="v">{summary.plannedQty.toLocaleString()} <span className="u">L</span></div>
</div>
<div className="dstat-sumcard primary">
<div className="k">전체 픽업량</div>
<div className="v">{summary.totalQty.toLocaleString()} <span className="u">L</span></div>
{summary.rate != null && <div className="sub">예상 대비 {summary.rate}%</div>}
</div>
<div className="dstat-sumcard">
<div className="k">전체 방문 </div>
<div className="v">
{summary.completed.toLocaleString()} <span className="u">/ {summary.target.toLocaleString()}</span>
</div>
</div>
</div>
)}
{/* 전체 개요 — 선택과 무관하게 항상 유지 */}
{(!ovLoading || rows.length > 0) && (
<div className="dstat-card">
<div className="dstat-card-head">
<span className="dstat-card-title">전체 드라이버 거리 대비 픽업량</span>
</div>
<EfficiencyScatter
rows={activeRows}
selectedId={driverId || null}
onSelect={(id) => setDriverId(String(id))}
/>
<div className="dstat-legend">
<span className="item">
<svg width="18" height="16">
<clipPath id="lgRun"><circle cx="9" cy="8" r="6" /></clipPath>
<circle cx="9" cy="8" r="6" fill="#eef3ef" />
<rect x="3" y="9" width="12" height="6" fill="#2e7d32" clipPath="url(#lgRun)" />
<circle cx="9" cy="8" r="6" fill="none" stroke="#2e7d32" strokeWidth="1.4" />
</svg>운행
</span>
<span className="item">
<svg width="20" height="16">
<clipPath id="lgEnd"><circle cx="9" cy="8" r="6" /></clipPath>
<circle cx="9" cy="8" r="6" fill="#eef3ef" />
<rect x="3" y="6" width="12" height="9" fill="#2e7d32" clipPath="url(#lgEnd)" />
<circle cx="9" cy="8" r="6" fill="none" stroke="#2e7d32" strokeWidth="1.4" />
<circle cx="14" cy="3.5" r="3" fill="#2e7d32" stroke="#fff" strokeWidth="0.8" />
<path d="M12.7 3.5 l0.9 0.9 l1.6 -1.9" fill="none" stroke="#fff" strokeWidth="0.9" strokeLinecap="round" strokeLinejoin="round" />
</svg>운행 종료
</span>
<span>채움 = 예상 대비 픽업량 · 마커 클릭 아래에 누적 차트</span>
</div>
</div>
)}
{/* 기타 운행 (오일 픽업 없음) — 거리 스트립 */}
{zeroRows.length > 0 && (
<div className="dstat-card" style={{ marginTop: 16 }}>
<div className="dstat-card-head">
<span className="dstat-card-title">기타 운행 (오일 픽업 없음) 이동거리</span>
</div>
<DistanceStrip rows={zeroRows} />
</div>
)}
{/* 단일 드라이버 드릴다운 — 아래에 추가로 등장 */}
{driverId && (
<div className="dstat-card" style={{ marginTop: 16 }}>
<div className="dstat-card-head">
<span className="dstat-card-title">
{selectedName} · 시간대별 누적{histLoading && ' · 불러오는 중…'}
</span>
<button className="dstat-btn ghost" onClick={() => setDriverId('')}>닫기 </button>
</div>
<DriverHistoryLine history={history} />
</div>
)}
</div>
)
}

View File

@ -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
}
// FritschCarlson: (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 <div className="dstat-empty">선택한 날짜에 픽업 이력이 없습니다.</div>
}
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 (
<g pointerEvents="none">
<rect x={tx} y={ty} width={tw} height={th} rx="8"
fill="#ffffff" stroke="#e2e8e4" strokeWidth="1" filter="url(#dstatLineShadow)" />
<text x={tx + 12} y={ty + 16} fontSize="10.5" fontWeight="700" fill="#1f2937">
{hm(new Date(p.t))} · {p.cust || '고객 미상'}
</text>
{lines.map((ln, i) => (
<text key={i} x={tx + 12} y={ty + 32 + i * 14} fontSize="9.5" fill="#5b6b60">{ln}</text>
))}
</g>
)
}
return (
<>
<div className="dstat-stats">
<div className="dstat-stat">
<div className="k">현재 누적 픽업량</div>
<div className="v">{Math.round(last.cum).toLocaleString()}</div>
</div>
<div className="dstat-stat">
<div className="k">누적 방문 완료</div>
<div className="v">{last.done}</div>
</div>
</div>
<svg className="dstat-chart" viewBox={`0 0 ${W} ${H}`} role="img"
aria-label="시간대별 누적 오일 픽업량">
<defs>
<filter id="dstatLineShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="1.5" stdDeviation="2.5" floodColor="#0b3d1f" floodOpacity="0.18" />
</filter>
<linearGradient id="dstatArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={ACCENT} stopOpacity="0.18" />
<stop offset="100%" stopColor={ACCENT} stopOpacity="0.02" />
</linearGradient>
</defs>
{xTicks.map((t, i) => (
<g key={`x${i}`}>
<line x1={xOf(t)} y1={PT} x2={xOf(t)} y2={PB} stroke="#d8e0db" />
<text x={xOf(t)} y={PB + 18} textAnchor="middle" fontSize="9.5" fill="#aab2ac">
{hm(new Date(t))}
</text>
</g>
))}
{yTicks.map((v) => (
<g key={`y${v}`}>
<line x1={PL} y1={yOf(v)} x2={PR} y2={yOf(v)} stroke="#d8e0db" />
<text x={PL - 8} y={yOf(v) + 3.5} textAnchor="end" fontSize="9.5" fill="#aab2ac">
{Math.round(v)}
</text>
</g>
))}
<text x={14} y={(PT + PB) / 2} textAnchor="middle" fontSize="10.5" fontWeight="600" fill="#5b6b60"
transform={`rotate(-90 14 ${(PT + PB) / 2})`}>누적 오일 픽업량 (L)</text>
<path d={areaPath} fill="url(#dstatArea)" stroke="none" />
<path d={linePath} fill="none" stroke={ACCENT} strokeWidth="2.5"
strokeLinejoin="round" strokeLinecap="round" />
{hoverIdx != null && pts[hoverIdx] && (
<line x1={xOf(pts[hoverIdx].t)} y1={PT} x2={xOf(pts[hoverIdx].t)} y2={PB}
stroke="#cdd6d0" strokeWidth="1" strokeDasharray="3 3" pointerEvents="none" />
)}
{pts.map((p, i) => {
const cx = xOf(p.t), cy = yOf(p.cum)
const on = hoverIdx === i
return (
<g key={i}
onMouseEnter={() => setHoverIdx(i)}
onMouseLeave={() => setHoverIdx((idx) => (idx === i ? null : idx))}>
<circle cx={cx} cy={cy} r="9" fill="transparent" style={{ cursor: 'pointer' }} />
<circle cx={cx} cy={cy} r={on ? 5 : 3} fill={ACCENT}
stroke="#fff" strokeWidth={on ? 1.5 : 0} />
</g>
)
})}
{hoverIdx != null && pts[hoverIdx] && renderTooltip(pts[hoverIdx])}
</svg>
</>
)
}

View File

@ -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 <path transform={t} d="M6.5 1 C6.5 1 11 6 11 9.2 A4.5 4.5 0 1 1 2 9.2 C2 6 6.5 1 6.5 1 Z" fill={c} />
if (type === 'dist') // ()
return (
<g transform={t} fill={c}>
<path d="M0.6 2.4 H7.4 V7.6 H0.6 Z" />
<path d="M7.4 4.2 H9.8 V5.8 H12.6 V7.6 H7.4 Z" />
<circle cx="3" cy="8.6" r="1.5" />
<circle cx="9.6" cy="8.6" r="1.5" />
<circle cx="3" cy="8.6" r="0.6" fill="#fff" />
<circle cx="9.6" cy="8.6" r="0.6" fill="#fff" />
</g>
)
if (type === 'visit') // ()
return (
<g transform={t}>
<path d="M6.5 0.6 C3.7 0.6 1.4 2.8 1.4 5.6 C1.4 9.2 6.5 13.4 6.5 13.4 C6.5 13.4 11.6 9.2 11.6 5.6 C11.6 2.8 9.3 0.6 6.5 0.6 Z" fill={c} />
<circle cx="6.5" cy="5.5" r="1.7" fill="#fff" />
</g>
)
if (type === 'time') // ()
return (
<g transform={t} stroke={c} strokeWidth="1.3" fill="none" strokeLinecap="round">
<circle cx="6.5" cy="6.8" r="5.4" />
<path d="M6.5 6.8 V3.7 M6.5 6.8 L9.2 6.8" />
</g>
)
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 <div className="dstat-empty">픽업이 기록된 드라이버가 없습니다.</div>
}
// ( )
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 (
<g key={d.rowId} className="dstat-marker"
onMouseEnter={() => setHoveredId(d.rowId)}
onMouseLeave={() => setHoveredId((id) => (id === d.rowId ? null : id))}
onClick={() => onSelect?.(d.driverId)}>
{isSel && <circle cx={cx} cy={cy} r={r + 5} fill="none" stroke={ACCENT} strokeWidth="1.5" opacity="0.5" />}
{empty ? (
// / =
<>
<circle cx={cx} cy={cy} r={r} fill="#ffffff" stroke="#b5c2b8" strokeWidth="1.5" strokeDasharray="2.5 2" />
<text x={cx} y={cy + 4} textAnchor="middle" fontSize="10" fontWeight="600" fill="#9aa5a0">{d.num}</text>
</>
) : (
// : (f = /)
<>
<clipPath id={clipId}><circle cx={cx} cy={cy} r={r} /></clipPath>
<circle cx={cx} cy={cy} r={r} fill="#eef3ef" filter="url(#dstatShadow)" />
{f > 0 && <path d={liquid} fill={ACCENT} clipPath={`url(#${clipId})`} />}
<circle cx={cx} cy={cy} r={r} fill="none" stroke={ACCENT} strokeWidth="1.6" />
<text x={cx} y={cy + 4} textAnchor="middle" fontSize="10" fontWeight="700"
fill="#ffffff" stroke="#1f6b3a" strokeWidth="2.4" paintOrder="stroke">{d.num}</text>
{!d.running && (
<>
<circle cx={cx + r * 0.8} cy={cy - r * 0.8} r="5" fill={ACCENT} stroke="#fff" strokeWidth="1" />
<path d={`M${(cx + r * 0.8 - 2.3).toFixed(1)} ${(cy - r * 0.8).toFixed(1)} l1.6 1.6 l2.7 -3.2`}
fill="none" stroke="#fff" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
</>
)}
</>
)}
<text x={d.label.side > 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}</text>
{d.target > 0 && (
<text x={d.label.side > 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}</text>
)}
</g>
)
}
// ( , )
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 (
<g pointerEvents="none">
<rect x={tx} y={ty} width={tw} height={th} rx="8"
fill="#ffffff" stroke="#e2e8e4" strokeWidth="1" filter="url(#dstatShadow)" />
<text x={tx + 12} y={ty + 17} fontSize="10.5" fontWeight="700" fill="#1f2937">
{d.numberLabel} · {d.name}
</text>
{rows.map((r, i) => {
const by = ty + 33 + i * lineH
return (
<g key={i}>
<TipIcon type={r.ic} x={tx + 12} y={by} c={ACCENT} />
<text x={tx + 30} y={by} fontSize="9.5" fill="#5b6b60">{r.t}</text>
</g>
)
})}
</g>
)
}
return (
<svg className="dstat-chart" viewBox={`0 0 ${W} ${H}`} role="img"
aria-label="이동거리 대비 오일 픽업량 산점도">
<defs>
<filter id="dstatShadow" x="-60%" y="-60%" width="220%" height="220%">
<feDropShadow dx="0" dy="1.5" stdDeviation="2" floodColor="#0b3d1f" floodOpacity="0.20" />
</filter>
</defs>
{/* 격자: 가로만, 옅게 */}
{yTicks.map((v) => (
<g key={`y${v}`}>
<line x1={PL} y1={yOf(v)} x2={PR} y2={yOf(v)} stroke="#d8e0db" />
<text x={PL - 8} y={yOf(v) + 3.5} textAnchor="end" fontSize="9.5" fill="#aab2ac">{Math.round(v)}</text>
</g>
))}
{xTicks.map((v) => (
<text key={`x${v}`} x={xOf(v)} y={PB + 18} textAnchor="middle" fontSize="9.5" fill="#aab2ac">
{Math.round(v)}
</text>
))}
{/* 축 제목: 오일 픽업량은 y축 옆 세로 라벨로 */}
<text x={14} y={(PT + PB) / 2} textAnchor="middle" fontSize="10.5" fontWeight="600" fill="#5b6b60"
transform={`rotate(-90 14 ${(PT + PB) / 2})`}>오일 픽업량 (L)</text>
<text x={(PL + PR) / 2} y={PB + 34} textAnchor="middle" fontSize="10.5" fontWeight="600" fill="#5b6b60">이동거리 (km)</text>
{/* 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 (
<line key={`c${d.rowId}`} x1={d.mx + d.label.side * R * 0.7} y1={d.my}
x2={near} y2={lcy} stroke="#d4dad6" strokeWidth="1" />
)
})}
{/* markers (hovered last = on top) */}
{order.map(renderMarker)}
{/* tooltip on top of everything */}
{hovered && renderTooltip(hovered)}
</svg>
)
}

View File

@ -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}`

View File

@ -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,