1053 lines
58 KiB
TypeScript
1053 lines
58 KiB
TypeScript
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import {
|
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
|
ResponsiveContainer, LineChart, Line, AreaChart, Area,
|
|
Cell, LabelList,
|
|
} from 'recharts';
|
|
import {
|
|
AlertTriangle, Truck, ChevronDown, Maximize2, Minimize2,
|
|
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
|
} from 'lucide-react';
|
|
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
|
|
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
|
import Blur from '../../components/Blur';
|
|
import { EmptyState, ErrorState, LoadingState } from '../../components/ui/surface';
|
|
import { weightedCompletionRate } from '../../shared/analytics/metrics';
|
|
import { formatMileageSnapshotDateTime } from './data-freshness';
|
|
import { assessVehicleCoverage } from './assessment-coverage';
|
|
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();
|
|
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
|
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
}
|
|
|
|
function fmtKm(value: number): string {
|
|
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
|
return value.toLocaleString();
|
|
}
|
|
|
|
function fmtPercent(value: number): string {
|
|
return `${value.toFixed(1)}%`;
|
|
}
|
|
|
|
function getTargetAssessment(target: TargetSummary, selectedYear?: number): TargetYearlyAssessment | null {
|
|
if (target.yearlyAssessments.length === 0) return null;
|
|
return target.yearlyAssessments.find(item => item.yearNumber === selectedYear) || target.yearlyAssessments[0];
|
|
}
|
|
|
|
function resolveAssessmentYear(target: TargetSummary, requestedYear?: number): number | undefined {
|
|
return getTargetAssessment(target, requestedYear)?.yearNumber;
|
|
}
|
|
|
|
function shortTargetName(name: string): string {
|
|
// Extract the number and a short description
|
|
const match = name.match(/(\d+)[辆台](.+)/);
|
|
if (!match) return name;
|
|
const count = match[1];
|
|
let desc = match[2];
|
|
// Simplify common patterns
|
|
desc = desc.replace('4.5T普货', '普货');
|
|
desc = desc.replace('4.5T冷链车', '冷藏车');
|
|
desc = desc.replace('4.5T冷链', '冷藏车');
|
|
desc = desc.replace('18T', '18T');
|
|
return `${count}台${desc}`;
|
|
}
|
|
|
|
function getRequestErrorMessage(error: unknown): string {
|
|
if (error instanceof Error && error.message && error.message !== 'Failed to fetch') {
|
|
return error.message;
|
|
}
|
|
return '无法获取考核目标数据,请检查服务连接后重试。';
|
|
}
|
|
|
|
export default function StatisticsView() {
|
|
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
|
|
parseMileageDrillContext(window.location.search)
|
|
));
|
|
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
|
const [targetsLoading, setTargetsLoading] = useState(true);
|
|
const [targetsError, setTargetsError] = useState<string | null>(null);
|
|
const [targetsRequestVersion, setTargetsRequestVersion] = useState(0);
|
|
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
|
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
|
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>(() => (
|
|
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 initialDrillContextRef = useRef(drillContext);
|
|
|
|
const selectedTarget = targets.find(t => t.id === selectedTargetId);
|
|
const selectedTargetVehicles = selectedTargetId === null ? [] : targetVehiclesMap[selectedTargetId] || [];
|
|
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
|
|
const selectedCoverage = selectedTarget
|
|
? assessVehicleCoverage(selectedTarget.declaredVehicleCount, selectedTarget.vehicleCount)
|
|
: null;
|
|
const selectedAssessmentYear = selectedAssessment?.yearNumber;
|
|
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
|
|
const selectedRemaining = selectedAssessment?.remaining ?? 0;
|
|
const selectedDaysLeft = selectedAssessment?.daysLeft ?? selectedTarget?.daysLeft ?? 0;
|
|
const selectedDailyTarget = selectedAssessment?.dailyTarget ?? (selectedDaysLeft > 0 ? selectedRemaining / selectedDaysLeft : 0);
|
|
const selectedQualifiedRate = selectedAssessment?.qualifiedRate ?? (selectedTarget?.vehicleCount ? selectedTarget.yearQualifiedCount / selectedTarget.vehicleCount * 100 : 0);
|
|
const selectedYearCompletionRate = weightedCompletionRate(
|
|
targets.flatMap(target => {
|
|
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
|
|
return assessment ? [{ completed: assessment.completed, target: assessment.target }] : [];
|
|
}),
|
|
);
|
|
const latestTrend = trendData[trendData.length - 1];
|
|
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 retryTargets = useCallback(() => {
|
|
setTargetsRequestVersion(version => version + 1);
|
|
}, []);
|
|
|
|
const loadCurrentTargetVehicles = useCallback((targetId: number) => {
|
|
if (targetVehicleRequestsRef.current.has(targetId)) return;
|
|
targetVehicleRequestsRef.current.add(targetId);
|
|
fetchTargetVehicles(targetId).then(vehicles => {
|
|
setTargetVehiclesMap(prev => ({ ...prev, [targetId]: vehicles }));
|
|
}).catch(() => {}).finally(() => {
|
|
targetVehicleRequestsRef.current.delete(targetId);
|
|
});
|
|
}, []);
|
|
|
|
const selectTarget = useCallback((targetId: number) => {
|
|
const target = targets.find(item => item.id === targetId);
|
|
const assessmentYear = target
|
|
? resolveAssessmentYear(target, assessmentYearMap[targetId])
|
|
: undefined;
|
|
setSelectedTargetId(targetId);
|
|
setExpandedTargetId(targetId);
|
|
setViewAllTargetId(null);
|
|
setViewAllSearch('');
|
|
commitDrillContext({
|
|
level: 'overview',
|
|
targetId,
|
|
assessmentYear,
|
|
sourcePriority: ['instrument'],
|
|
}, 'replace');
|
|
}, [assessmentYearMap, commitDrillContext, targets]);
|
|
|
|
const openAttributionGroup = useCallback((value: string) => {
|
|
if (selectedTargetId === null) return;
|
|
setViewAllTargetId(selectedTargetId);
|
|
setViewAllTargetName(selectedTarget?.targetName || '考核车辆');
|
|
setViewAllSearch('');
|
|
setViewAllDate(getDefaultDate());
|
|
setViewAllHistoricalVehicles(null);
|
|
commitDrillContext({
|
|
level: 'breakdown',
|
|
targetId: selectedTargetId,
|
|
assessmentYear: selectedAssessmentYear,
|
|
dimension: attributionDimension,
|
|
dimensionValue: value,
|
|
sourcePriority: ['instrument'],
|
|
}, 'push');
|
|
}, [attributionDimension, commitDrillContext, selectedAssessmentYear, selectedTarget?.targetName, selectedTargetId]);
|
|
|
|
const openTargetVehicles = useCallback((target: TargetSummary) => {
|
|
const assessmentYear = resolveAssessmentYear(target, assessmentYearMap[target.id]);
|
|
setViewAllTargetId(target.id);
|
|
setViewAllTargetName(target.targetName);
|
|
setViewAllSearch('');
|
|
setViewAllDate(getDefaultDate());
|
|
setViewAllHistoricalVehicles(null);
|
|
commitDrillContext({
|
|
level: 'breakdown',
|
|
targetId: target.id,
|
|
assessmentYear,
|
|
dimension: 'assessment-target',
|
|
dimensionValue: target.targetName,
|
|
sourcePriority: ['instrument'],
|
|
}, 'push');
|
|
}, [assessmentYearMap, commitDrillContext]);
|
|
|
|
const closeVehiclePanel = useCallback(() => {
|
|
setViewAllTargetId(null);
|
|
setViewAllSearch('');
|
|
if (drillContext.level === 'breakdown') {
|
|
commitDrillContext({
|
|
level: 'overview',
|
|
targetId: selectedTargetId || undefined,
|
|
assessmentYear: selectedAssessmentYear,
|
|
sourcePriority: ['instrument'],
|
|
}, 'replace');
|
|
}
|
|
}, [commitDrillContext, drillContext.level, selectedAssessmentYear, 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,
|
|
assessmentYear: selectedAssessmentYear,
|
|
plate: vehicle.plateNumber,
|
|
startDate: viewAllDate,
|
|
endDate: viewAllDate,
|
|
sourcePriority: ['instrument'],
|
|
},
|
|
);
|
|
window.history.pushState(null, '', url);
|
|
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
|
}, [selectedAssessmentYear, selectedTargetId, viewAllDate, viewAllTargetId]);
|
|
|
|
// Load targets on mount and through the shared retry path.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setTargetsLoading(true);
|
|
setTargetsError(null);
|
|
fetchTargets().then(data => {
|
|
if (cancelled) return;
|
|
const initialContext = initialDrillContextRef.current;
|
|
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
|
|
const ordered = focused
|
|
? [focused, ...data.filter(item => item.id !== focused.id)]
|
|
: data;
|
|
setTargets(ordered);
|
|
const initialTarget = ordered.find(item => item.id === selectedTargetId) || focused;
|
|
if (initialTarget) {
|
|
setSelectedTargetId(initialTarget.id);
|
|
setExpandedTargetId(initialTarget.id);
|
|
const initialYears = Object.fromEntries(ordered.map(item => [
|
|
item.id,
|
|
resolveAssessmentYear(item, item.id === initialContext.targetId ? initialContext.assessmentYear : undefined) || 1,
|
|
]));
|
|
setAssessmentYearMap(initialYears);
|
|
|
|
if (initialContext.targetId === initialTarget.id) {
|
|
const resolvedYear = initialYears[initialTarget.id];
|
|
if (resolvedYear && resolvedYear !== initialContext.assessmentYear) {
|
|
const normalizedContext = { ...initialContext, assessmentYear: resolvedYear };
|
|
window.history.replaceState(null, '', buildMileageDrillUrl(window.location, normalizedContext));
|
|
setDrillContext(normalizedContext);
|
|
}
|
|
}
|
|
} else {
|
|
setSelectedTargetId(null);
|
|
setExpandedTargetId(null);
|
|
setAssessmentYearMap({});
|
|
}
|
|
}).catch(error => {
|
|
if (!cancelled) setTargetsError(getRequestErrorMessage(error));
|
|
}).finally(() => {
|
|
if (!cancelled) setTargetsLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [targetsRequestVersion]);
|
|
|
|
// Load trend when selectedTargetId changes
|
|
useEffect(() => {
|
|
if (selectedTargetId === null) return;
|
|
setExpandedTargetId(selectedTargetId);
|
|
if (!targetVehiclesMap[selectedTargetId]) {
|
|
loadCurrentTargetVehicles(selectedTargetId);
|
|
}
|
|
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
|
|
}, [selectedTargetId]);
|
|
|
|
// Re-fetch target vehicles when viewAllDate changes
|
|
useEffect(() => {
|
|
if (viewAllTargetId === null) return;
|
|
setViewAllLoading(true);
|
|
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(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);
|
|
const target = targets.find(item => item.id === next.targetId);
|
|
const resolvedYear = target ? resolveAssessmentYear(target, next.assessmentYear) : undefined;
|
|
if (resolvedYear) {
|
|
setAssessmentYearMap(prev => ({ ...prev, [next.targetId!]: resolvedYear }));
|
|
}
|
|
}
|
|
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);
|
|
}, [targets]);
|
|
|
|
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]);
|
|
|
|
if (targetsLoading && targets.length === 0) {
|
|
return <LoadingState label="正在加载考核统计" />;
|
|
}
|
|
|
|
if (targetsError && targets.length === 0) {
|
|
return (
|
|
<ErrorState
|
|
title="考核统计加载失败"
|
|
message={targetsError}
|
|
onRetry={retryTargets}
|
|
retrying={targetsLoading}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (!targetsLoading && targets.length === 0) {
|
|
return (
|
|
<EmptyState
|
|
title="暂无考核目标"
|
|
description="当前权限范围内没有有效考核目标"
|
|
/>
|
|
);
|
|
}
|
|
|
|
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 */}
|
|
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
|
|
{targets.map(target => (
|
|
<button
|
|
key={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'
|
|
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
|
}`}
|
|
>
|
|
{shortTargetName(target.targetName)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{targetsError && (
|
|
<ErrorState
|
|
title="考核统计刷新失败"
|
|
message={`${targetsError} 当前仍展示上一次成功加载的数据。`}
|
|
onRetry={retryTargets}
|
|
retrying={targetsLoading}
|
|
variant="inline"
|
|
/>
|
|
)}
|
|
|
|
{selectedTarget && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="grid grid-cols-2 gap-2 md:grid-cols-4"
|
|
>
|
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
|
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">考核压力</div>
|
|
<div className={`mt-1 text-lg font-black ${pressureLevel === '健康' ? 'text-emerald-600' : pressureLevel === '关注' ? 'text-amber-600' : 'text-rose-600'}`}>
|
|
{pressureLevel}
|
|
</div>
|
|
<div className="mt-1 text-[10px] font-bold text-slate-400">完成率 {fmtPercent(selectedCompletion)}</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
|
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">剩余缺口</div>
|
|
<div className="mt-1 text-lg font-black text-slate-900">{fmtKm(Math.max(0, selectedRemaining))}<span className="ml-1 text-[10px] text-slate-400">km</span></div>
|
|
<div className="mt-1 text-[10px] font-bold text-slate-400">剩余 {selectedDaysLeft} 天</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
|
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">日均需完成</div>
|
|
<div className="mt-1 text-lg font-black text-blue-600">
|
|
{selectedDaysLeft > 0 ? (
|
|
<>
|
|
{fmtKm(selectedDailyTarget)}<span className="ml-1 text-[10px] text-slate-400">km</span>
|
|
</>
|
|
) : '已到期'}
|
|
</div>
|
|
<div className="mt-1 text-[10px] font-bold text-slate-400">按当前考核口径</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
|
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">最新日变化</div>
|
|
<div className={`mt-1 text-lg font-black ${trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
|
{trendDelta >= 0 ? '+' : ''}{fmtKm(trendDelta)}
|
|
</div>
|
|
<div className="mt-1 text-[10px] font-bold text-slate-400">达标率 {fmtPercent(selectedQualifiedRate)}</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{selectedTarget && selectedCoverage && selectedCoverage.status !== 'matched' && (
|
|
<div role="alert" className="flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-amber-900">
|
|
<AlertTriangle size={15} className="mt-0.5 shrink-0 text-amber-600" />
|
|
<div className="min-w-0 text-[11px] font-bold leading-5">
|
|
<span className="font-black">考核车辆配置不一致:</span>
|
|
目标 {selectedCoverage.declaredCount} 台,当前有效 {selectedCoverage.activeCount} 台,
|
|
{selectedCoverage.status === 'missing'
|
|
? `缺少 ${Math.abs(selectedCoverage.difference)} 台`
|
|
: `超出 ${selectedCoverage.difference} 台`}。
|
|
完成率、缺口和归因按当前有效车辆计算。
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedTarget && (
|
|
<MileageAttributionView
|
|
vehicles={selectedTargetVehicles}
|
|
dimension={attributionDimension}
|
|
selectedValue={selectedBreakdownValue}
|
|
loading={!targetVehiclesMap[selectedTarget.id]}
|
|
targetMileagePerVehicle={selectedTarget.annualMileagePerVehicle * (selectedAssessmentYear || 1)}
|
|
expectedShortfallMileage={selectedAssessment?.remaining}
|
|
assessmentLabel={selectedAssessment?.label}
|
|
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">
|
|
{/* KPI Cards in Landscape — linked to selected target */}
|
|
{(() => {
|
|
const sel = selectedTarget;
|
|
return (
|
|
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
|
|
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
|
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">今日总里程</div>
|
|
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
|
{fmtKm(sel?.todayTotal ?? 0)}
|
|
<span className="text-blue-500 text-[10px] ml-1">KM</span>
|
|
</div>
|
|
</div>
|
|
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
|
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">累计总里程</div>
|
|
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
|
{fmtKm(sel?.cumulativeTotal ?? 0)}
|
|
<span className="text-blue-500 text-[10px] ml-1">KM</span>
|
|
</div>
|
|
</div>
|
|
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
|
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">考核车辆</div>
|
|
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
|
{sel?.vehicleCount ?? 0}
|
|
<span className="text-blue-500 text-[10px] ml-1">台</span>
|
|
</div>
|
|
</div>
|
|
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
|
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">完成率</div>
|
|
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
|
{selectedCompletion.toFixed(1)}
|
|
<span className="text-blue-500 text-[10px] ml-1">%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
<div className="bg-white p-4 rounded-2xl shadow-sm border border-slate-100 flex-1 flex flex-col min-h-[300px] overflow-hidden">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-1 h-4 bg-blue-600 rounded-full" />
|
|
<h3 className="text-sm font-bold text-slate-800">7天里程趋势</h3>
|
|
</div>
|
|
<div className="flex bg-slate-50 p-1 rounded-lg">
|
|
{(['bar', 'line', 'area'] as const).map(type => (
|
|
<button
|
|
key={type}
|
|
onClick={() => setChartType(type)}
|
|
className={`px-2 py-1 rounded-md text-[10px] font-bold transition-all ${
|
|
chartType === type ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'
|
|
}`}
|
|
>
|
|
{type === 'bar' ? '柱状' : type === 'line' ? '折线' : '面积'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="h-[280px] w-full min-w-0">
|
|
<ResponsiveContainer width="100%" height={280} minWidth={0}>
|
|
{chartType === 'bar' ? (
|
|
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
|
|
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
|
|
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
|
|
<Tooltip cursor={{ fill: '#f8fafc', fillOpacity: 0.1 }} contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
|
|
<Bar dataKey="mileage" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={20}>
|
|
<LabelList dataKey="mileage" position="top" style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
|
|
{trendData.map((_entry: TrendPoint, index: number) => (
|
|
<Cell key={`cell-${index}`} fill={index === trendData.length - 1 ? '#2563eb' : '#60a5fa'} />
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
) : chartType === 'line' ? (
|
|
<LineChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
|
|
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
|
|
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
|
|
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
|
|
<Line type="monotone" dataKey="mileage" stroke="#3b82f6" strokeWidth={3} dot={{ r: 4, fill: '#3b82f6' }}>
|
|
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
|
|
</Line>
|
|
</LineChart>
|
|
) : (
|
|
<AreaChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
|
<defs>
|
|
<linearGradient id="colorMileage" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3}/>
|
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
|
|
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
|
|
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
|
|
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
|
|
<Area type="monotone" dataKey="mileage" stroke="#3b82f6" fillOpacity={1} fill="url(#colorMileage)">
|
|
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
|
|
</Area>
|
|
</AreaChart>
|
|
)}
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Side: Summary Section */}
|
|
<div className="w-full landscape:w-1/3 flex-shrink-0 space-y-2 flex flex-col landscape:overflow-hidden">
|
|
<div className="flex items-center justify-between px-2 flex-shrink-0">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<div className="w-1 h-4 bg-blue-600 rounded-full" />
|
|
<div className="min-w-0">
|
|
<h3 className="truncate text-xs font-black text-slate-700">当前车型考核详情</h3>
|
|
<p className="mt-0.5 truncate text-[9px] font-bold text-slate-400">{selectedTarget?.targetName || '请选择车型'}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsTableFullscreen(true)}
|
|
className="p-1.5 bg-white text-slate-400 rounded-lg border border-slate-100 shadow-sm hover:text-blue-600 transition-colors"
|
|
>
|
|
<Maximize2 size={14} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
|
|
{(selectedTarget ? [selectedTarget] : []).map((target, idx) => (
|
|
(() => {
|
|
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
|
|
const primaryCompletion = assessment?.completionRate ?? target.avgCompletion;
|
|
const primaryQualified = assessment?.qualifiedCount ?? target.yearQualifiedCount;
|
|
const primaryQualifiedLabel = assessment ? `${assessment.label}达标:` : '达标:';
|
|
return (
|
|
<div
|
|
key={idx}
|
|
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);
|
|
}}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
|
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center flex-shrink-0">
|
|
<Truck size={14} className="text-slate-400" />
|
|
</div>
|
|
<div className="overflow-hidden flex-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs font-black text-slate-900">{target.targetName}</span>
|
|
<span className="text-[8px] px-1 rounded bg-blue-50 text-blue-600 font-bold">{target.vehicleCount}台</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[9px] text-slate-400">{assessment ? `${assessment.label}完成:` : '完成率:'}</span>
|
|
<span className={`text-[9px] font-bold ${primaryCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{fmtPercent(primaryCompletion)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[9px] text-slate-400">{primaryQualifiedLabel}</span>
|
|
<span className="text-[9px] font-bold text-slate-600">{primaryQualified}台</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-right flex-shrink-0 ml-2 flex items-center gap-3">
|
|
<div className="flex flex-col items-end">
|
|
<div className="text-sm font-black text-slate-900 leading-none mb-0.5">
|
|
{fmtKm(target.todayTotal)} <span className="text-[8px] text-slate-300 font-bold uppercase">KM</span>
|
|
</div>
|
|
<div className="text-[8px] font-bold text-slate-300">
|
|
累计: {fmtKm(target.cumulativeTotal)} KM
|
|
</div>
|
|
</div>
|
|
<motion.div
|
|
animate={{ rotate: 180 }}
|
|
className="text-slate-300"
|
|
>
|
|
<ChevronDown size={14} />
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
|
|
<AnimatePresence initial={false} mode="wait">
|
|
{expandedTargetId === target.id && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
|
|
<div className="col-span-2 flex items-center justify-between gap-3 bg-blue-50/70 p-2 rounded-lg">
|
|
<span className="text-[10px] font-black text-blue-700">考核年度</span>
|
|
<select
|
|
value={assessment?.yearNumber || ''}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onChange={(e) => {
|
|
e.stopPropagation();
|
|
const assessmentYear = Number(e.target.value);
|
|
setAssessmentYearMap(prev => ({ ...prev, [target.id]: assessmentYear }));
|
|
commitDrillContext({
|
|
...drillContext,
|
|
targetId: target.id,
|
|
assessmentYear,
|
|
}, 'replace');
|
|
}}
|
|
className="bg-white border border-blue-100 rounded-lg px-2 py-1 text-[10px] font-bold text-blue-700 outline-none"
|
|
>
|
|
{target.yearlyAssessments.map(item => (
|
|
<option key={item.yearNumber} value={item.yearNumber}>
|
|
{item.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-span-2 bg-slate-50/80 rounded-lg p-2 grid grid-cols-2 gap-3">
|
|
<div className="space-y-0.5">
|
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核区间</p>
|
|
{target.periods.map((p, i) => (
|
|
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
|
|
))}
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核里程</p>
|
|
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
|
|
</div>
|
|
</div>
|
|
{assessment ? (
|
|
<>
|
|
<div className="space-y-0.5">
|
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}区间</p>
|
|
{(assessment.periods.length > 0 ? assessment.periods : [`${assessment.startDate} ~ ${assessment.endDate}`]).map((period, i) => (
|
|
<p key={i} className="text-[10px] font-black text-slate-700">{period}</p>
|
|
))}
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}任务/辆</p>
|
|
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle * assessment.yearNumber)} km</p>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已进入车辆</p>
|
|
<p className="text-[10px] font-black text-blue-600">{assessment.vehicleCount} 台</p>
|
|
{assessment.vehicleCount < target.vehicleCount && (
|
|
<p className="text-[8px] font-bold text-slate-400">其余 {target.vehicleCount - assessment.vehicleCount} 台尚未进入{assessment.label}</p>
|
|
)}
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}需完成</p>
|
|
<p className="text-[10px] font-black text-slate-700">{fmtKm(assessment.target)} km</p>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已完成</p>
|
|
<p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p>
|
|
<p className="text-[8px] font-bold text-slate-300">
|
|
数据截至 {formatMileageSnapshotDateTime(target.snapshotUpdatedAt)}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}完成率</p>
|
|
<p className="text-[10px] font-black text-blue-600">{fmtPercent(assessment.completionRate)}</p>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}达标率</p>
|
|
<p className="text-[10px] font-black text-emerald-600">{fmtPercent(assessment.qualifiedRate)} ({assessment.qualifiedCount}台)</p>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}剩余里程</p>
|
|
<p className="text-[10px] font-black text-rose-500">{fmtKm(assessment.remaining)} km</p>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}日均需完成</p>
|
|
<p className="text-[10px] font-black text-blue-500">
|
|
{assessment.daysLeft > 0 ? `${fmtKm(assessment.dailyTarget)} km` : '考核已到期'}
|
|
</p>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="col-span-2 bg-slate-50 p-2 rounded-lg text-[10px] font-bold text-slate-400">
|
|
暂无第一年度车辆,当前车型车辆已进入后续考核年度。
|
|
</div>
|
|
)}
|
|
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
|
|
<span className="text-[9px] font-bold text-slate-500">{assessment ? `${assessment.label}剩余考核天数` : '剩余考核天数'}</span>
|
|
<span className="text-[10px] font-black text-slate-900">{assessment?.daysLeft ?? target.daysLeft} 天</span>
|
|
</div>
|
|
|
|
{/* Vehicle List Detail */}
|
|
<div className="col-span-2 space-y-2 mt-2">
|
|
<div className="flex items-center justify-between px-1">
|
|
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">车辆明细 (前5台)</span>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
openTargetVehicles(target);
|
|
}}
|
|
className="text-[8px] text-blue-500 font-bold hover:underline"
|
|
>
|
|
查看全部
|
|
</button>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{(targetVehiclesMap[target.id] || []).slice(0, 5).map(tv => (
|
|
<div key={tv.plateNumber} className="bg-slate-50/50/50 px-2 py-1.5 rounded-lg flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-mono font-bold text-slate-700"><Blur>{tv.plateNumber}</Blur></span>
|
|
<span className="text-[7px] px-1 rounded bg-green-100 text-green-600 font-bold">
|
|
在线
|
|
</span>
|
|
</div>
|
|
<div className="text-right">
|
|
<span className="text-[10px] font-black text-blue-600">{tv.todayMileage}</span>
|
|
<span className="text-[8px] text-slate-400 ml-1">KM</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
})()
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fullscreen Table Overlay */}
|
|
<AnimatePresence>
|
|
{isTableFullscreen && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-[100] bg-slate-950 flex flex-col overflow-hidden"
|
|
>
|
|
{/* Top bar: compact inline KPI */}
|
|
<div className="flex-shrink-0 px-3 py-2 border-b border-slate-800/60 flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-0.5 h-4 bg-blue-500 rounded-full"></div>
|
|
<h2 className="text-white font-bold text-xs">车型考核汇总</h2>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-[10px]">
|
|
<span className="text-slate-500">今日 <span className="text-white font-black">{fmtKm(targets.reduce((sum, t) => sum + t.todayTotal, 0))}</span> <span className="text-blue-400">km</span></span>
|
|
<span className="text-slate-700">|</span>
|
|
<span className="text-slate-500">累计 <span className="text-white font-black">{fmtKm(targets.reduce((sum, t) => sum + t.cumulativeTotal, 0))}</span> <span className="text-blue-400">km</span></span>
|
|
<span className="text-slate-700">|</span>
|
|
<span className="text-slate-500">车辆 <span className="text-white font-black">{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}</span> 台</span>
|
|
<span className="text-slate-700">|</span>
|
|
<span className="text-slate-500">所选年度完成率 <span className="text-white font-black">{selectedYearCompletionRate.toFixed(1)}</span> <span className="text-blue-400">%</span></span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={retryTargets}
|
|
disabled={targetsLoading}
|
|
title="刷新考核统计"
|
|
aria-label="刷新考核统计"
|
|
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
|
|
>
|
|
<RotateCcw size={13} className={targetsLoading ? 'animate-spin' : ''} />
|
|
</button>
|
|
<button
|
|
onClick={() => setIsTableFullscreen(false)}
|
|
className="p-1.5 text-slate-500 hover:text-white transition-colors"
|
|
>
|
|
<Minimize2 size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{targetsError && (
|
|
<div role="alert" className="flex items-center justify-between gap-3 border-b border-rose-900/50 bg-rose-950/60 px-3 py-2 text-[10px] font-bold text-rose-200">
|
|
<span>{targetsError} 当前仍展示上一次成功加载的数据。</span>
|
|
<button type="button" onClick={retryTargets} disabled={targetsLoading} className="shrink-0 text-rose-100 underline disabled:opacity-60">
|
|
重试
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Table Area */}
|
|
<div className="flex-1 overflow-auto">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead className="sticky top-0 bg-slate-900 z-10">
|
|
<tr className="border-b border-slate-800/60">
|
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]">车型</th>
|
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12">台数</th>
|
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]">年度进度</th>
|
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度任务/辆</th>
|
|
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center">年度达标</th>
|
|
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-center">50%达标</th>
|
|
<th className="px-3 py-2 text-[10px] font-bold text-white uppercase text-right">今日里程</th>
|
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度目标</th>
|
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">已完成</th>
|
|
<th className="px-3 py-2 text-[10px] font-bold text-rose-400 uppercase text-right">未完成</th>
|
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14">余天</th>
|
|
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-right">日均需完成</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-800/30">
|
|
{targets.map((target, idx) => {
|
|
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
|
|
const completion = assessment?.completionRate ?? target.avgCompletion;
|
|
const qualified = assessment?.qualifiedCount ?? target.yearQualifiedCount;
|
|
const halfQualified = assessment?.halfQualifiedCount ?? target.halfQualifiedCount;
|
|
const goal = assessment?.target ?? target.currentYearTarget;
|
|
const completed = assessment?.completed ?? target.currentYearCompleted;
|
|
const remainingMileage = assessment?.remaining ?? target.remaining;
|
|
const days = assessment?.daysLeft ?? target.daysLeft;
|
|
const daily = assessment?.dailyTarget ?? target.dailyTarget;
|
|
const taskPerVehicle = target.annualMileagePerVehicle * (assessment?.yearNumber || 1);
|
|
return (
|
|
<tr key={idx} className="hover:bg-slate-800/20 transition-colors">
|
|
<td className="px-3 py-3 sticky left-0 bg-slate-950 z-10 border-r border-slate-800/40">
|
|
<div className="text-xs font-bold text-white whitespace-nowrap">{target.targetName}</div>
|
|
<div className="text-[9px] text-blue-400 font-bold mt-0.5">{assessment?.label || '当前年度'}</div>
|
|
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((p, i) => <span key={i} className="block">{p}</span>)}</div>
|
|
</td>
|
|
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
|
|
<td className="px-3 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full ${completion >= 90 ? 'bg-emerald-500' : completion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
|
style={{ width: `${Math.min(completion, 100)}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-[10px] font-black text-white w-10 text-right">{completion.toFixed(1)}%</span>
|
|
</div>
|
|
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
|
|
<span>{fmtKm(completed)}</span>
|
|
<span>/ {fmtKm(goal)} km</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(taskPerVehicle)} km</td>
|
|
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{qualified}</td>
|
|
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{halfQualified}</td>
|
|
<td className="px-3 py-3 text-xs font-black text-white text-right">{fmtKm(target.todayTotal)} km</td>
|
|
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(goal)} km</td>
|
|
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(completed)} km</td>
|
|
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(remainingMileage)} km</td>
|
|
<td className="px-3 py-3 text-xs text-slate-300 text-center">{days}</td>
|
|
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">
|
|
{assessment && days === 0 ? '考核已到期' : `${fmtKm(daily)} km`}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* View All Vehicles Side Panel */}
|
|
<AnimatePresence>
|
|
{viewAllTargetId !== null && (
|
|
<>
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={closeVehiclePanel}
|
|
className="fixed inset-0 z-[110] bg-slate-950/60 backdrop-blur-sm"
|
|
/>
|
|
<motion.div
|
|
initial={{ x: '100%' }}
|
|
animate={{ x: 0 }}
|
|
exit={{ x: '100%' }}
|
|
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
|
className="fixed top-0 right-0 bottom-0 w-full max-w-sm z-[120] bg-white shadow-2xl flex flex-col"
|
|
>
|
|
<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 || selectedTarget?.targetName || '考核车辆'}
|
|
</h3>
|
|
<p className="mt-1 text-xs font-bold uppercase tracking-widest text-slate-400">
|
|
{selectedAssessment ? `${selectedAssessment.label} · ` : ''}车辆实时明细清单
|
|
</p>
|
|
{selectedBreakdownValue && drillContext.dimension !== 'assessment-target' && (
|
|
<p className="mt-1 text-[10px] font-bold text-blue-600">{selectedBreakdownValue}</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={closeVehiclePanel}
|
|
className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400 hover:text-slate-900"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-6 py-3 border-b border-slate-50 space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
|
|
<input
|
|
type="text"
|
|
placeholder="搜索车牌号..."
|
|
value={viewAllSearch}
|
|
onChange={(e) => setViewAllSearch(e.target.value)}
|
|
className="w-full pl-9 pr-3 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
|
|
/>
|
|
</div>
|
|
<div className="relative flex-shrink-0">
|
|
<Calendar className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" size={13} />
|
|
<input
|
|
type="date"
|
|
value={viewAllDate}
|
|
onChange={(e) => setViewAllDate(e.target.value)}
|
|
className="pl-8 pr-2 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all w-[130px]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
|
{viewAllLoading ? '加载中...' : (() => {
|
|
const totalKm = searchedViewAllVehicles.reduce((sum, vehicle) => sum + (vehicle.todayMileage || 0), 0);
|
|
return `${searchedViewAllVehicles.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
|
|
})()}
|
|
</span>
|
|
<button
|
|
onClick={() => setViewAllSort(prev => prev === 'desc' ? 'asc' : 'desc')}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-black active:scale-95 transition-all"
|
|
>
|
|
<ArrowUpDown size={12} />
|
|
{viewAllSort === 'desc' ? '降序' : '升序'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
|
|
{[...searchedViewAllVehicles].sort((a, b) => {
|
|
const valA = a.todayMileage || 0;
|
|
const valB = b.todayMileage || 0;
|
|
return viewAllSort === 'desc' ? valB - valA : valA - valB;
|
|
}).map(tv => (
|
|
<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">
|
|
<Truck size={14} className="text-slate-400" />
|
|
</div>
|
|
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${tv.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} />
|
|
</div>
|
|
<div className="overflow-hidden flex-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{tv.plateNumber}</Blur></span>
|
|
<span className={`text-[8px] px-1 rounded ${tv.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
|
|
{tv.isOnline ? '在线' : '离线'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-[8px] text-slate-300 font-bold">{tv.rentStatus || ''}{tv.department ? ` · ${tv.department.replace('业务', '')}` : ''}</span>
|
|
<span className="text-[9px] font-bold text-slate-600 truncate">{tv.customer || '-'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-right flex-shrink-0 ml-2">
|
|
<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>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="p-4 bg-slate-50 border-t border-slate-100">
|
|
<button
|
|
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"
|
|
>
|
|
关闭明细
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|