From 44b9d5a05c913ffffbe4aa3399e24c8b5bc2970e Mon Sep 17 00:00:00 2001 From: Hyojin Ahn Date: Thu, 18 Jun 2026 16:27:38 -0400 Subject: [PATCH] =?UTF-8?q?=EC=98=88=EC=83=81=EB=9F=89=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=AA=A9=ED=91=9C=EB=9F=89=EC=9C=BC=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/crm/DistanceStrip.jsx | 30 ++++++++++++++-- src/features/crm/DriverDailyStatPage.jsx | 44 ++++++++++++------------ src/features/crm/EfficiencyScatter.jsx | 24 ++++++------- 3 files changed, 62 insertions(+), 36 deletions(-) diff --git a/src/features/crm/DistanceStrip.jsx b/src/features/crm/DistanceStrip.jsx index fbe36a7..a370c7d 100644 --- a/src/features/crm/DistanceStrip.jsx +++ b/src/features/crm/DistanceStrip.jsx @@ -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 if (type === 'dist') // 이동거리(트럭) return ( @@ -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 ( setHoveredId(d.rowId)} onMouseLeave={() => setHoveredId((id) => (id === d.rowId ? null : id))}> - - {d.num} + {hasOil ? ( + // 기름 있음 = 채운 마커 (고스트 아님) + <> + + {d.num} + + ) : ( + // 기름 없음 = 고스트 + <> + + {d.num} + + )} + {ended && ( + // 운행 종료 체크 + <> + + + + )} {d.name} ) diff --git a/src/features/crm/DriverDailyStatPage.jsx b/src/features/crm/DriverDailyStatPage.jsx index dcdcbab..564672c 100644 --- a/src/features/crm/DriverDailyStatPage.jsx +++ b/src/features/crm/DriverDailyStatPage.jsx @@ -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) - completed += Number(r.ddsCompletedCount ?? 0) - target += Number(r.ddsPlannedCount ?? 0) + totalQty += Number(r.ddsTotalQty ?? 0) + targetQty += Number(r.ddsTargetQty ?? 0) + completed += Number(r.ddsCompletedCount ?? 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 && (
-
전체 예상량
-
{summary.plannedQty.toLocaleString()} L
+
전체 목표량
+
{summary.targetQty.toLocaleString()} L
전체 픽업량
{summary.totalQty.toLocaleString()} L
- {summary.rate != null &&
예상 대비 {summary.rate}%
} + {summary.rate != null &&
목표 대비 {summary.rate}%
}
전체 방문 수
- {summary.completed.toLocaleString()} / {summary.target.toLocaleString()} + {summary.completed.toLocaleString()} / {summary.targetCount.toLocaleString()}
@@ -167,10 +167,10 @@ export default function DriverDailyStatPage() { {(!ovLoading || rows.length > 0) && (
- 전체 드라이버 — 거리 대비 픽업량 + UCO 드라이버 — 거리 대비 픽업량
setDriverId(String(id))} /> @@ -193,18 +193,18 @@ export default function DriverDailyStatPage() { 운행 종료 - 채움 = 예상 대비 픽업량 · 마커 클릭 → 아래에 누적 차트 + 채움 = 목표 대비 픽업량 · 마커 클릭 → 아래에 누적 차트
)} - {/* 기타 운행 (오일 픽업 없음) — 거리 스트립 */} - {zeroRows.length > 0 && ( + {/* 기타 운행 (INSTALL·목표 없음) — 거리 스트립 */} + {etcRows.length > 0 && (
- 기타 운행 (오일 픽업 없음) — 이동거리 + 기타 운행 (목표 없음) — 이동거리
- +
)} diff --git a/src/features/crm/EfficiencyScatter.jsx b/src/features/crm/EfficiencyScatter.jsx index af9489d..ebfbf73 100644 --- a/src/features/crm/EfficiencyScatter.jsx +++ b/src/features/crm/EfficiencyScatter.jsx @@ -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 }) { 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} - {d.target > 0 && ( + {d.targetCount > 0 && ( 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} + fontSize="9.5" fill="#8a948c">{d.completed} / {d.targetCount} )}
) @@ -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