226 lines
9.1 KiB
JavaScript
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>
|
|
)
|
|
}
|