goi-web/src/features/crm/DriverDailyStatPage.jsx

226 lines
9.1 KiB
JavaScript

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
// 목표 있음(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 totalQty = 0, targetQty = 0, completed = 0, targetCount = 0
for (const r of rows) {
totalQty += Number(r.ddsTotalQty ?? 0)
targetQty += Number(r.ddsTargetQty ?? 0)
completed += Number(r.ddsCompletedCount ?? 0)
targetCount += Number(r.ddsTargetCount ?? 0)
}
const rate = targetQty > 0 ? Math.round((totalQty / targetQty) * 100) : null
return {
totalQty: Math.round(totalQty),
targetQty: Math.round(targetQty),
completed,
targetCount,
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.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>}
</div>
<div className="dstat-sumcard">
<div className="k">전체 방문 </div>
<div className="v">
{summary.completed.toLocaleString()} <span className="u">/ {summary.targetCount.toLocaleString()}</span>
</div>
</div>
</div>
)}
{/* 전체 개요 — 선택과 무관하게 항상 유지 */}
{(!ovLoading || rows.length > 0) && (
<div className="dstat-card">
<div className="dstat-card-head">
<span className="dstat-card-title">UCO 드라이버 거리 대비 픽업량</span>
</div>
<EfficiencyScatter
rows={ucoRows}
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>
)}
{/* 기타 운행 (INSTALL·목표 없음) — 거리 스트립 */}
{etcRows.length > 0 && (
<div className="dstat-card" style={{ marginTop: 16 }}>
<div className="dstat-card-head">
<span className="dstat-card-title">기타 운행 (목표 없음) 이동거리</span>
</div>
<DistanceStrip rows={etcRows} />
</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>
)
}