refactor: add mileage attribution drilldown
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
@@ -13,6 +13,16 @@ import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint }
|
||||
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
||||
import Blur from '../../components/Blur';
|
||||
import { weightedCompletionRate } from '../../shared/analytics/metrics';
|
||||
import MileageAttributionView from './MileageAttributionView';
|
||||
import {
|
||||
filterAttributionVehicles,
|
||||
type AttributionDimension,
|
||||
} from './attribution';
|
||||
import {
|
||||
buildMileageDrillUrl,
|
||||
parseMileageDrillContext,
|
||||
type MileageDrillContext,
|
||||
} from './drill-context';
|
||||
|
||||
function getDefaultDate(): string {
|
||||
const now = new Date();
|
||||
@@ -61,23 +71,34 @@ function shortTargetName(name: string): string {
|
||||
|
||||
export default function StatisticsView() {
|
||||
const currentDateLabel = getCurrentDateLabel();
|
||||
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
|
||||
parseMileageDrillContext(window.location.search)
|
||||
));
|
||||
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null);
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(drillContext.targetId || null);
|
||||
const [attributionDimension, setAttributionDimension] = useState<AttributionDimension>(() => (
|
||||
drillContext.level === 'breakdown' && drillContext.dimension === 'customer' ? 'customer' : 'department'
|
||||
));
|
||||
|
||||
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
|
||||
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
|
||||
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
|
||||
const [assessmentYearMap, setAssessmentYearMap] = useState<Record<number, number>>({});
|
||||
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
|
||||
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(() => (
|
||||
drillContext.level === 'breakdown' ? drillContext.targetId || null : null
|
||||
));
|
||||
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
|
||||
const [viewAllSearch, setViewAllSearch] = useState('');
|
||||
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
|
||||
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
||||
const [viewAllLoading, setViewAllLoading] = useState(false);
|
||||
const [viewAllHistoricalVehicles, setViewAllHistoricalVehicles] = useState<TargetVehicle[] | null>(null);
|
||||
const targetVehicleRequestsRef = useRef(new Set<number>());
|
||||
|
||||
const selectedTarget = targets.find(t => t.id === selectedTargetId);
|
||||
const selectedTargetVehicles = selectedTargetId === null ? [] : targetVehiclesMap[selectedTargetId] || [];
|
||||
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
|
||||
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
|
||||
const selectedRemaining = selectedAssessment?.remaining ?? 0;
|
||||
@@ -94,6 +115,81 @@ export default function StatisticsView() {
|
||||
const previousTrend = trendData[trendData.length - 2];
|
||||
const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0;
|
||||
const pressureLevel = selectedCompletion >= 90 ? '健康' : selectedCompletion >= 70 ? '关注' : '高压';
|
||||
const selectedBreakdownValue = drillContext.level === 'breakdown'
|
||||
? drillContext.dimensionValue
|
||||
: undefined;
|
||||
|
||||
const commitDrillContext = useCallback((next: MileageDrillContext, mode: 'push' | 'replace') => {
|
||||
const url = buildMileageDrillUrl(window.location, next);
|
||||
window.history[mode === 'push' ? 'pushState' : 'replaceState'](null, '', url);
|
||||
setDrillContext(next);
|
||||
}, []);
|
||||
|
||||
const loadCurrentTargetVehicles = useCallback((targetId: number) => {
|
||||
if (targetVehicleRequestsRef.current.has(targetId)) return;
|
||||
targetVehicleRequestsRef.current.add(targetId);
|
||||
fetchTargetVehicles(targetId, getDefaultDate()).then(vehicles => {
|
||||
setTargetVehiclesMap(prev => ({ ...prev, [targetId]: vehicles }));
|
||||
}).catch(() => {}).finally(() => {
|
||||
targetVehicleRequestsRef.current.delete(targetId);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectTarget = useCallback((targetId: number) => {
|
||||
setSelectedTargetId(targetId);
|
||||
setExpandedTargetId(targetId);
|
||||
setViewAllTargetId(null);
|
||||
setViewAllSearch('');
|
||||
commitDrillContext({
|
||||
level: 'overview',
|
||||
targetId,
|
||||
sourcePriority: ['instrument'],
|
||||
}, 'replace');
|
||||
}, [commitDrillContext]);
|
||||
|
||||
const openAttributionGroup = useCallback((value: string) => {
|
||||
if (selectedTargetId === null) return;
|
||||
setViewAllTargetId(selectedTargetId);
|
||||
setViewAllTargetName(selectedTarget?.targetName || '考核车辆');
|
||||
setViewAllSearch('');
|
||||
setViewAllDate(getDefaultDate());
|
||||
setViewAllHistoricalVehicles(null);
|
||||
commitDrillContext({
|
||||
level: 'breakdown',
|
||||
targetId: selectedTargetId,
|
||||
dimension: attributionDimension,
|
||||
dimensionValue: value,
|
||||
sourcePriority: ['instrument'],
|
||||
}, 'push');
|
||||
}, [attributionDimension, commitDrillContext, selectedTarget?.targetName, selectedTargetId]);
|
||||
|
||||
const closeVehiclePanel = useCallback(() => {
|
||||
setViewAllTargetId(null);
|
||||
setViewAllSearch('');
|
||||
if (drillContext.level === 'breakdown') {
|
||||
commitDrillContext({
|
||||
level: 'overview',
|
||||
targetId: selectedTargetId || undefined,
|
||||
sourcePriority: ['instrument'],
|
||||
}, 'replace');
|
||||
}
|
||||
}, [commitDrillContext, drillContext.level, selectedTargetId]);
|
||||
|
||||
const openVehicleDiagnostic = useCallback((vehicle: TargetVehicle) => {
|
||||
const url = buildMileageDrillUrl(
|
||||
{ pathname: window.location.pathname, search: window.location.search, hash: '#mileage' },
|
||||
{
|
||||
level: 'vehicle',
|
||||
targetId: viewAllTargetId || selectedTargetId || undefined,
|
||||
plate: vehicle.plateNumber,
|
||||
startDate: viewAllDate,
|
||||
endDate: viewAllDate,
|
||||
sourcePriority: ['instrument'],
|
||||
},
|
||||
);
|
||||
window.history.pushState(null, '', url);
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
}, [selectedTargetId, viewAllDate, viewAllTargetId]);
|
||||
|
||||
// Load targets on mount
|
||||
useEffect(() => {
|
||||
@@ -103,13 +199,11 @@ export default function StatisticsView() {
|
||||
? [focused, ...data.filter(item => item.id !== focused.id)]
|
||||
: data;
|
||||
setTargets(ordered);
|
||||
if (ordered.length > 0 && !selectedTargetId) {
|
||||
setSelectedTargetId(focused.id);
|
||||
setExpandedTargetId(focused.id);
|
||||
const initialTarget = ordered.find(item => item.id === selectedTargetId) || focused;
|
||||
if (initialTarget) {
|
||||
setSelectedTargetId(initialTarget.id);
|
||||
setExpandedTargetId(initialTarget.id);
|
||||
setAssessmentYearMap(Object.fromEntries(ordered.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1])));
|
||||
fetchTargetVehicles(focused.id).then(vehicles => {
|
||||
setTargetVehiclesMap(prev => ({ ...prev, [focused.id]: vehicles }));
|
||||
}).catch(() => {});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
@@ -119,9 +213,7 @@ export default function StatisticsView() {
|
||||
if (selectedTargetId === null) return;
|
||||
setExpandedTargetId(selectedTargetId);
|
||||
if (!targetVehiclesMap[selectedTargetId]) {
|
||||
fetchTargetVehicles(selectedTargetId).then(vehicles => {
|
||||
setTargetVehiclesMap(prev => ({ ...prev, [selectedTargetId]: vehicles }));
|
||||
}).catch(() => {});
|
||||
loadCurrentTargetVehicles(selectedTargetId);
|
||||
}
|
||||
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
|
||||
}, [selectedTargetId]);
|
||||
@@ -129,12 +221,52 @@ export default function StatisticsView() {
|
||||
// Re-fetch target vehicles when viewAllDate changes
|
||||
useEffect(() => {
|
||||
if (viewAllTargetId === null) return;
|
||||
if (viewAllDate === getDefaultDate()) {
|
||||
setViewAllHistoricalVehicles(null);
|
||||
setViewAllLoading(false);
|
||||
return;
|
||||
}
|
||||
setViewAllLoading(true);
|
||||
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
|
||||
setTargetVehiclesMap(prev => ({ ...prev, [viewAllTargetId]: data }));
|
||||
setViewAllHistoricalVehicles(data);
|
||||
}).catch(() => {}).finally(() => setViewAllLoading(false));
|
||||
}, [viewAllTargetId, viewAllDate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const next = parseMileageDrillContext(window.location.search);
|
||||
setDrillContext(next);
|
||||
if (next.targetId) setSelectedTargetId(next.targetId);
|
||||
if (next.level === 'breakdown' && next.targetId && next.dimensionValue) {
|
||||
setAttributionDimension(next.dimension === 'customer' ? 'customer' : 'department');
|
||||
setViewAllTargetId(next.targetId);
|
||||
} else {
|
||||
setViewAllTargetId(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
const viewAllVehicles = useMemo(() => {
|
||||
const vehicles = viewAllHistoricalVehicles
|
||||
|| (viewAllTargetId === null ? [] : targetVehiclesMap[viewAllTargetId] || []);
|
||||
if (
|
||||
drillContext.level === 'breakdown'
|
||||
&& drillContext.dimensionValue
|
||||
&& (drillContext.dimension === 'department' || drillContext.dimension === 'customer')
|
||||
) {
|
||||
return filterAttributionVehicles(vehicles, drillContext.dimension, drillContext.dimensionValue);
|
||||
}
|
||||
return vehicles;
|
||||
}, [drillContext, targetVehiclesMap, viewAllHistoricalVehicles, viewAllTargetId]);
|
||||
|
||||
const searchedViewAllVehicles = useMemo(() => (
|
||||
viewAllVehicles.filter(vehicle => (
|
||||
vehicle.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase())
|
||||
))
|
||||
), [viewAllSearch, viewAllVehicles]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
|
||||
{/* Project Selector */}
|
||||
@@ -142,10 +274,7 @@ export default function StatisticsView() {
|
||||
{targets.map(target => (
|
||||
<button
|
||||
key={target.id}
|
||||
onClick={() => {
|
||||
setSelectedTargetId(target.id);
|
||||
setExpandedTargetId(target.id);
|
||||
}}
|
||||
onClick={() => selectTarget(target.id)}
|
||||
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
|
||||
selectedTargetId === target.id
|
||||
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
|
||||
@@ -196,6 +325,20 @@ export default function StatisticsView() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectedTarget && (
|
||||
<MileageAttributionView
|
||||
vehicles={selectedTargetVehicles}
|
||||
dimension={attributionDimension}
|
||||
selectedValue={selectedBreakdownValue}
|
||||
loading={!targetVehiclesMap[selectedTarget.id]}
|
||||
onDimensionChange={(dimension) => {
|
||||
setAttributionDimension(dimension);
|
||||
if (drillContext.level === 'breakdown') closeVehiclePanel();
|
||||
}}
|
||||
onSelect={openAttributionGroup}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
|
||||
{/* Left Side: Trend Chart / Dashboard Sidebar */}
|
||||
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
|
||||
@@ -334,11 +477,6 @@ export default function StatisticsView() {
|
||||
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
|
||||
onClick={() => {
|
||||
setExpandedTargetId(target.id);
|
||||
if (!targetVehiclesMap[target.id]) {
|
||||
fetchTargetVehicles(target.id).then(data => {
|
||||
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
|
||||
}).catch(() => {});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -650,7 +788,7 @@ export default function StatisticsView() {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setViewAllTargetId(null)}
|
||||
onClick={closeVehiclePanel}
|
||||
className="fixed inset-0 z-[110] bg-slate-950/60 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
@@ -662,14 +800,16 @@ export default function StatisticsView() {
|
||||
>
|
||||
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-slate-900">{viewAllTargetName}</h3>
|
||||
<h3 className="text-lg font-black text-slate-900">
|
||||
{viewAllTargetName || selectedTarget?.targetName || '考核车辆'}
|
||||
</h3>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">车辆实时明细清单</p>
|
||||
{selectedBreakdownValue && (
|
||||
<p className="mt-1 text-[10px] font-bold text-blue-600">{selectedBreakdownValue}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewAllTargetId(null);
|
||||
setViewAllSearch('');
|
||||
}}
|
||||
onClick={closeVehiclePanel}
|
||||
className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400 hover:text-slate-900"
|
||||
>
|
||||
<X size={20} />
|
||||
@@ -701,9 +841,8 @@ export default function StatisticsView() {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
{viewAllLoading ? '加载中...' : (() => {
|
||||
const filtered = (viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv => tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase()));
|
||||
const totalKm = filtered.reduce((sum, tv) => sum + (tv.todayMileage || 0), 0);
|
||||
return `${filtered.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
|
||||
const totalKm = searchedViewAllVehicles.reduce((sum, vehicle) => sum + (vehicle.todayMileage || 0), 0);
|
||||
return `${searchedViewAllVehicles.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
|
||||
})()}
|
||||
</span>
|
||||
<button
|
||||
@@ -717,14 +856,17 @@ export default function StatisticsView() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
|
||||
{(viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv =>
|
||||
tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase())
|
||||
).sort((a, b) => {
|
||||
{[...searchedViewAllVehicles].sort((a, b) => {
|
||||
const valA = a.todayMileage || 0;
|
||||
const valB = b.todayMileage || 0;
|
||||
return viewAllSort === 'desc' ? valB - valA : valA - valB;
|
||||
}).map(tv => (
|
||||
<div key={tv.plateNumber} className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between hover:border-blue-200 transition-all">
|
||||
<button
|
||||
type="button"
|
||||
key={tv.plateNumber}
|
||||
onClick={() => openVehicleDiagnostic(tv)}
|
||||
className="flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-2 text-left shadow-sm transition-all hover:border-blue-200 hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
|
||||
@@ -749,13 +891,13 @@ export default function StatisticsView() {
|
||||
<div className="text-sm font-black text-blue-600">{tv.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
|
||||
<div className="text-[9px] font-bold text-slate-300 mt-0.5">累计: {fmtKm(tv.totalMileage || 0)} km</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 border-t border-slate-100">
|
||||
<button
|
||||
onClick={() => setViewAllTargetId(null)}
|
||||
onClick={closeVehiclePanel}
|
||||
className="w-full py-3 bg-slate-900 text-white rounded-xl text-sm font-bold shadow-lg shadow-slate-200 active:scale-[0.98] transition-all"
|
||||
>
|
||||
关闭明细
|
||||
|
||||
Reference in New Issue
Block a user