예상량에서 목표량으로 수정
This commit is contained in:
parent
0342c96b28
commit
44b9d5a05c
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue