dashboard 초기버전 추가
This commit is contained in:
parent
bfaa627fdf
commit
e4ca0b47fa
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 <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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue