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

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 ( ). .
const W = 720, H = 152, PL = 24, PR = 696, R = 11
const CY = 56
const ACCENT = '#2e7d32'
const kmOf = (s) => {
const n = parseFloat(String(s ?? '').replace(/[^0-9.]/g, ''))
@ -19,6 +20,8 @@ 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 === '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}>
@ -66,6 +69,7 @@ export default function DistanceStrip({ rows }) {
status: r.ddsStatus,
startAt: r.ddsStartAt,
endAt: r.ddsEndAt,
oil: Number(r.ddsTotalQty ?? 0),
}))
.sort((a, b) => a.km - b.km)
@ -105,6 +109,7 @@ export default function DistanceStrip({ rows }) {
const renderTooltip = (d) => {
const rows = [
...(d.oil > 0 ? [{ ic: 'oil', t: `${Math.round(d.oil).toLocaleString()} L` }] : []),
{ 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)}` },
@ -160,12 +165,33 @@ export default function DistanceStrip({ rows }) {
const isHover = d.rowId === hoveredId
const r = isHover ? R * 1.18 : R
const ly = d.lane === 'below' ? CY + R + 13 : CY - R - 7
const hasOil = d.oil > 0
const ended = d.status === 'C'
return (
<g key={d.rowId} className="dstat-marker"
onMouseEnter={() => setHoveredId(d.rowId)}
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" />
<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>
</g>
)

View File

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

View File

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