예상량에서 목표량으로 수정

This commit is contained in:
Hyojin Ahn 2026-06-18 16:27:38 -04:00
parent 0342c96b28
commit 44b9d5a05c
3 changed files with 62 additions and 36 deletions

View File

@ -4,6 +4,7 @@ import { useMemo, useState } from 'react'
// x = (km), y ( ). . // x = (km), y ( ). .
const W = 720, H = 152, PL = 24, PR = 696, R = 11 const W = 720, H = 152, PL = 24, PR = 696, R = 11
const CY = 56 const CY = 56
const ACCENT = '#2e7d32'
const kmOf = (s) => { const kmOf = (s) => {
const n = parseFloat(String(s ?? '').replace(/[^0-9.]/g, '')) const n = parseFloat(String(s ?? '').replace(/[^0-9.]/g, ''))
@ -19,6 +20,8 @@ const statusText = (s) => (s === 'C' ? '운행 종료' : s === 'P' ? '일시정
// (BI) // (BI)
const TipIcon = ({ type, x, y, c }) => { const TipIcon = ({ type, x, y, c }) => {
const t = `translate(${x} ${y - 9}) scale(0.85)` 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') // () if (type === 'dist') // ()
return ( return (
<g transform={t} fill={c}> <g transform={t} fill={c}>
@ -66,6 +69,7 @@ export default function DistanceStrip({ rows }) {
status: r.ddsStatus, status: r.ddsStatus,
startAt: r.ddsStartAt, startAt: r.ddsStartAt,
endAt: r.ddsEndAt, endAt: r.ddsEndAt,
oil: Number(r.ddsTotalQty ?? 0),
})) }))
.sort((a, b) => a.km - b.km) .sort((a, b) => a.km - b.km)
@ -105,6 +109,7 @@ export default function DistanceStrip({ rows }) {
const renderTooltip = (d) => { const renderTooltip = (d) => {
const rows = [ const rows = [
...(d.oil > 0 ? [{ ic: 'oil', t: `${Math.round(d.oil).toLocaleString()} L` }] : []),
{ ic: 'dist', t: d.kmText || `${d.km} km` }, { ic: 'dist', t: d.kmText || `${d.km} km` },
{ ic: 'dur', t: d.minText }, { ic: 'dur', t: d.minText },
{ ic: 'time', t: d.endAt ? `${hm(d.startAt)} ~ ${hm(d.endAt)}` : `${hm(d.startAt)} ~ ${statusText(d.status)}` }, { ic: 'time', t: d.endAt ? `${hm(d.startAt)} ~ ${hm(d.endAt)}` : `${hm(d.startAt)} ~ ${statusText(d.status)}` },
@ -160,12 +165,33 @@ export default function DistanceStrip({ rows }) {
const isHover = d.rowId === hoveredId const isHover = d.rowId === hoveredId
const r = isHover ? R * 1.18 : R const r = isHover ? R * 1.18 : R
const ly = d.lane === 'below' ? CY + R + 13 : CY - R - 7 const ly = d.lane === 'below' ? CY + R + 13 : CY - R - 7
const hasOil = d.oil > 0
const ended = d.status === 'C'
return ( return (
<g key={d.rowId} className="dstat-marker" <g key={d.rowId} className="dstat-marker"
onMouseEnter={() => setHoveredId(d.rowId)} onMouseEnter={() => setHoveredId(d.rowId)}
onMouseLeave={() => setHoveredId((id) => (id === d.rowId ? null : id))}> onMouseLeave={() => setHoveredId((id) => (id === d.rowId ? null : id))}>
{hasOil ? (
// = ( )
<>
<circle cx={d.mx} cy={CY} r={r} fill="#eef3ef" stroke={ACCENT} strokeWidth="1.6" />
<text x={d.mx} y={CY + 4} textAnchor="middle" fontSize="10" fontWeight="700" fill={ACCENT}>{d.num}</text>
</>
) : (
// =
<>
<circle cx={d.mx} cy={CY} r={r} fill="#ffffff" stroke="#b5c2b8" strokeWidth="1.5" strokeDasharray="2.5 2" /> <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={CY + 4} textAnchor="middle" fontSize="10" fontWeight="600" fill="#9aa5a0">{d.num}</text>
</>
)}
{ended && (
//
<>
<circle cx={d.mx + r * 0.8} cy={CY - r * 0.8} r="5" fill={ACCENT} stroke="#fff" strokeWidth="1" />
<path d={`M${(d.mx + 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.mx} y={ly} textAnchor="middle" fontSize="10.5" fontWeight="500" fill="#5b6b60">{d.name}</text> <text x={d.mx} y={ly} textAnchor="middle" fontSize="10.5" fontWeight="500" fill="#5b6b60">{d.name}</text>
</g> </g>
) )

View File

@ -86,25 +86,25 @@ export default function DriverDailyStatPage() {
const selectedName = drivers.find((d) => String(d.id) === String(driverId))?.name const selectedName = drivers.find((d) => String(d.id) === String(driverId))?.name
// () / 0() // (UCO ) / (INSTALL· )
const activeRows = useMemo(() => rows.filter((r) => Number(r.ddsTotalQty ?? 0) > 0), [rows]) const ucoRows = useMemo(() => rows.filter((r) => Number(r.ddsTargetQty ?? 0) > 0), [rows])
const zeroRows = useMemo(() => rows.filter((r) => Number(r.ddsTotalQty ?? 0) <= 0), [rows]) const etcRows = useMemo(() => rows.filter((r) => Number(r.ddsTargetQty ?? 0) <= 0), [rows])
// ( , ) // ( , )
const summary = useMemo(() => { const summary = useMemo(() => {
let plannedQty = 0, totalQty = 0, completed = 0, target = 0 let totalQty = 0, targetQty = 0, completed = 0, targetCount = 0
for (const r of rows) { for (const r of rows) {
plannedQty += Number(r.ddsPlannedQty ?? 0)
totalQty += Number(r.ddsTotalQty ?? 0) totalQty += Number(r.ddsTotalQty ?? 0)
targetQty += Number(r.ddsTargetQty ?? 0)
completed += Number(r.ddsCompletedCount ?? 0) completed += Number(r.ddsCompletedCount ?? 0)
target += Number(r.ddsPlannedCount ?? 0) targetCount += Number(r.ddsTargetCount ?? 0)
} }
const rate = plannedQty > 0 ? Math.round((totalQty / plannedQty) * 100) : null const rate = targetQty > 0 ? Math.round((totalQty / targetQty) * 100) : null
return { return {
plannedQty: Math.round(plannedQty),
totalQty: Math.round(totalQty), totalQty: Math.round(totalQty),
targetQty: Math.round(targetQty),
completed, completed,
target, targetCount,
rate, rate,
} }
}, [rows]) }, [rows])
@ -146,18 +146,18 @@ export default function DriverDailyStatPage() {
{rows.length > 0 && ( {rows.length > 0 && (
<div className="dstat-summary"> <div className="dstat-summary">
<div className="dstat-sumcard"> <div className="dstat-sumcard">
<div className="k">전체 예상</div> <div className="k">전체 목표</div>
<div className="v">{summary.plannedQty.toLocaleString()} <span className="u">L</span></div> <div className="v">{summary.targetQty.toLocaleString()} <span className="u">L</span></div>
</div> </div>
<div className="dstat-sumcard primary"> <div className="dstat-sumcard primary">
<div className="k">전체 픽업량</div> <div className="k">전체 픽업량</div>
<div className="v">{summary.totalQty.toLocaleString()} <span className="u">L</span></div> <div className="v">{summary.totalQty.toLocaleString()} <span className="u">L</span></div>
{summary.rate != null && <div className="sub">예상 대비 {summary.rate}%</div>} {summary.rate != null && <div className="sub">목표 대비 {summary.rate}%</div>}
</div> </div>
<div className="dstat-sumcard"> <div className="dstat-sumcard">
<div className="k">전체 방문 </div> <div className="k">전체 방문 </div>
<div className="v"> <div className="v">
{summary.completed.toLocaleString()} <span className="u">/ {summary.target.toLocaleString()}</span> {summary.completed.toLocaleString()} <span className="u">/ {summary.targetCount.toLocaleString()}</span>
</div> </div>
</div> </div>
</div> </div>
@ -167,10 +167,10 @@ export default function DriverDailyStatPage() {
{(!ovLoading || rows.length > 0) && ( {(!ovLoading || rows.length > 0) && (
<div className="dstat-card"> <div className="dstat-card">
<div className="dstat-card-head"> <div className="dstat-card-head">
<span className="dstat-card-title">전체 드라이버 거리 대비 픽업량</span> <span className="dstat-card-title">UCO 드라이버 거리 대비 픽업량</span>
</div> </div>
<EfficiencyScatter <EfficiencyScatter
rows={activeRows} rows={ucoRows}
selectedId={driverId || null} selectedId={driverId || null}
onSelect={(id) => setDriverId(String(id))} onSelect={(id) => setDriverId(String(id))}
/> />
@ -193,18 +193,18 @@ export default function DriverDailyStatPage() {
<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" /> <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>운행 종료 </svg>운행 종료
</span> </span>
<span>채움 = 예상 대비 픽업량 · 마커 클릭 아래에 누적 차트</span> <span>채움 = 목표 대비 픽업량 · 마커 클릭 아래에 누적 차트</span>
</div> </div>
</div> </div>
)} )}
{/* 기타 운행 (오일 픽업 없음) — 거리 스트립 */} {/* 기타 운행 (INSTALL·목표 없음) — 거리 스트립 */}
{zeroRows.length > 0 && ( {etcRows.length > 0 && (
<div className="dstat-card" style={{ marginTop: 16 }}> <div className="dstat-card" style={{ marginTop: 16 }}>
<div className="dstat-card-head"> <div className="dstat-card-head">
<span className="dstat-card-title">기타 운행 (오일 픽업 없음) 이동거리</span> <span className="dstat-card-title">기타 운행 (목표 없음) 이동거리</span>
</div> </div>
<DistanceStrip rows={zeroRows} /> <DistanceStrip rows={etcRows} />
</div> </div>
)} )}

View File

@ -65,10 +65,10 @@ export default function EfficiencyScatter({ rows, onSelect, selectedId }) {
name: r.ddsMainDriver ?? '', name: r.ddsMainDriver ?? '',
km: kmOf(r.ddsKm), km: kmOf(r.ddsKm),
kmText: r.ddsKm ?? '', kmText: r.ddsKm ?? '',
oil: Number(r.ddsTotalQty ?? 0), oil: Number(r.ddsTotalQty ?? 0), //
plannedQty: Number(r.ddsPlannedQty ?? 0), targetQty: Number(r.ddsTargetQty ?? 0), // ()
completed: Number(r.ddsCompletedCount ?? 0), completed: Number(r.ddsCompletedCount ?? 0),
target: Number(r.ddsPlannedCount ?? 0), targetCount: Number(r.ddsTargetCount ?? 0), // ()
running: r.ddsStatus !== 'C', // C = running: r.ddsStatus !== 'C', // C =
startAt: r.ddsStartAt, startAt: r.ddsStartAt,
endAt: r.ddsEndAt, endAt: r.ddsEndAt,
@ -144,8 +144,8 @@ export default function EfficiencyScatter({ rows, onSelect, selectedId }) {
// / ( ) // / ( )
const offsets = [0, -16, 16, -32, 32, -48, 48, -64, 64, -80, 80] const offsets = [0, -16, 16, -32, 32, -48, 48, -64, 64, -80, 80]
items.forEach((d) => { items.forEach((d) => {
const w = Math.max(textW(d.name) + 6, d.target > 0 ? 46 : 0) const w = Math.max(textW(d.name) + 6, d.targetCount > 0 ? 46 : 0)
const h = d.target > 0 ? 24 : 14 const h = d.targetCount > 0 ? 24 : 14
let chosen = null, best = null, bestPen = Infinity let chosen = null, best = null, bestPen = Infinity
for (const dy of offsets) { for (const dy of offsets) {
for (const side of [1, -1]) { for (const side of [1, -1]) {
@ -198,7 +198,7 @@ export default function EfficiencyScatter({ rows, onSelect, selectedId }) {
const r = R * k const r = R * k
const cx = d.mx, cy = d.my const cx = d.mx, cy = d.my
const empty = d.oil <= 0 // ( , ) const empty = d.oil <= 0 // ( , )
const f = d.plannedQty > 0 ? Math.min(1, d.oil / d.plannedQty) : 1 // 픽업량 / 예상(예상 없으면 가득) const f = d.targetQty > 0 ? Math.min(1, d.oil / d.targetQty) : 1 // 픽업량 / 목표(예상 없으면 가득)
const clipId = `dstatLiq${d.rowId}` const clipId = `dstatLiq${d.rowId}`
// ( ) + . f=0/1 . // ( ) + . f=0/1 .
@ -245,10 +245,10 @@ export default function EfficiencyScatter({ rows, onSelect, selectedId }) {
<text x={d.label.side > 0 ? d.label.lx : d.label.lx + d.label.w} <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'} y={d.label.ly + 10} textAnchor={d.label.side > 0 ? 'start' : 'end'}
fontSize="10.5" fontWeight="500" fill="#28332b">{d.name}</text> fontSize="10.5" fontWeight="500" fill="#28332b">{d.name}</text>
{d.target > 0 && ( {d.targetCount > 0 && (
<text x={d.label.side > 0 ? d.label.lx : d.label.lx + d.label.w} <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'} y={d.label.ly + 22} textAnchor={d.label.side > 0 ? 'start' : 'end'}
fontSize="9.5" fill="#8a948c">{d.completed} / {d.target}</text> fontSize="9.5" fill="#8a948c">{d.completed} / {d.targetCount}</text>
)} )}
</g> </g>
) )
@ -256,13 +256,13 @@ export default function EfficiencyScatter({ rows, onSelect, selectedId }) {
// ( , ) // ( , )
const renderTooltip = (d) => { const renderTooltip = (d) => {
const pct = d.plannedQty > 0 ? Math.round((d.oil / d.plannedQty) * 100) : null const pct = d.targetQty > 0 ? Math.round((d.oil / d.targetQty) * 100) : null
const rows = [ const rows = [
{ ic: 'oil', t: pct != null { ic: 'oil', t: d.targetQty > 0
? `${Math.round(d.oil).toLocaleString()} / ${Math.round(d.plannedQty).toLocaleString()} L (${pct}%)` ? `${Math.round(d.oil).toLocaleString()} / ${Math.round(d.targetQty).toLocaleString()} L (${pct}%)`
: `${Math.round(d.oil).toLocaleString()} L` }, : `${Math.round(d.oil).toLocaleString()} L` },
{ ic: 'dist', t: d.kmText || `${d.km} km` }, { ic: 'dist', t: d.kmText || `${d.km} km` },
{ ic: 'visit', t: d.target > 0 ? `${d.completed} / ${d.target}` : '방문 목표 없음' }, { ic: 'visit', t: d.targetCount > 0 ? `${d.completed} / ${d.targetCount}` : '방문 목표 없음' },
{ ic: 'time', t: d.endAt ? `${fmtHM(d.startAt)} ~ ${fmtHM(d.endAt)}` : `${fmtHM(d.startAt)} ~ 운행 중` }, { ic: 'time', t: d.endAt ? `${fmtHM(d.startAt)} ~ ${fmtHM(d.endAt)}` : `${fmtHM(d.startAt)} ~ 운행 중` },
] ]
const lineH = 16 const lineH = 16