refactor: modularize application domains
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { ArrowUpDown, Calendar, Search, Truck, X } from 'lucide-react';
|
||||
import Blur from '../../../components/Blur';
|
||||
import type { TargetVehicle } from '../types';
|
||||
import {
|
||||
filterAndSortTargetVehicles,
|
||||
fmtKm,
|
||||
totalTodayMileage,
|
||||
type VehicleSort,
|
||||
} from './model';
|
||||
|
||||
interface AllVehiclesPanelProps {
|
||||
targetId: number | null;
|
||||
targetName: string;
|
||||
vehicles: TargetVehicle[];
|
||||
date: string;
|
||||
loading: boolean;
|
||||
onDateChange: (date: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AllVehiclesPanel({
|
||||
targetId,
|
||||
targetName,
|
||||
vehicles,
|
||||
date,
|
||||
loading,
|
||||
onDateChange,
|
||||
onClose,
|
||||
}: AllVehiclesPanelProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<VehicleSort>('desc');
|
||||
const filteredVehicles = filterAndSortTargetVehicles(vehicles, search, sort);
|
||||
const totalKm = totalTodayMileage(filteredVehicles);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{targetId !== null && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
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">{targetName}</h3>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">车辆实时明细清单</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
setSearch('');
|
||||
}}
|
||||
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={search}
|
||||
onChange={(event) => setSearch(event.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={date}
|
||||
onChange={(event) => onDateChange(event.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">
|
||||
{loading ? '加载中...' : `${filteredVehicles.length} 辆 · 合计 ${fmtKm(totalKm)} km`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSort(previous => previous === '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} />
|
||||
{sort === 'desc' ? '降序' : '升序'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
|
||||
{filteredVehicles.map(vehicle => (
|
||||
<div key={vehicle.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">
|
||||
<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 ${vehicle.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>{vehicle.plateNumber}</Blur></span>
|
||||
<span className={`text-[8px] px-1 rounded ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
|
||||
{vehicle.isOnline ? '在线' : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[8px] text-slate-300 font-bold">{vehicle.rentStatus || ''}{vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''}</span>
|
||||
<span className="text-[9px] font-bold text-slate-600 truncate">{vehicle.customer || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-2">
|
||||
<div className="text-sm font-black text-blue-600">{vehicle.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(vehicle.totalMileage || 0)} km</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 border-t border-slate-100">
|
||||
<button
|
||||
onClick={onClose}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { Minimize2, RotateCcw } from 'lucide-react';
|
||||
import type { TargetSummary } from '../types';
|
||||
import { deriveTargetTableRow, fmtKm, summarizeTargets } from './model';
|
||||
|
||||
interface FullscreenTargetTableProps {
|
||||
isOpen: boolean;
|
||||
targets: TargetSummary[];
|
||||
assessmentYearMap: Record<number, number>;
|
||||
onRefresh: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function FullscreenTargetTable({
|
||||
isOpen,
|
||||
targets,
|
||||
assessmentYearMap,
|
||||
onRefresh,
|
||||
onClose,
|
||||
}: FullscreenTargetTableProps) {
|
||||
const totals = summarizeTargets(targets, assessmentYearMap);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<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"
|
||||
>
|
||||
<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(totals.today)}</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(totals.cumulative)}</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">{totals.vehicleCount}</span> 台</span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">所选年度完成率 <span className="text-white font-black">{totals.averageCompletion.toFixed(1)}</span> <span className="text-blue-400">%</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<RotateCcw size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-slate-500 hover:text-white transition-colors"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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, index) => {
|
||||
const row = deriveTargetTableRow(target, assessmentYearMap[target.id]);
|
||||
return (
|
||||
<tr key={index} 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">{row.assessment?.label || '当前年度'}</div>
|
||||
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((period, periodIndex) => <span key={periodIndex} className="block">{period}</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 ${row.completion >= 90 ? 'bg-emerald-500' : row.completion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
||||
style={{ width: `${Math.min(row.completion, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-black text-white w-10 text-right">{row.completion.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
|
||||
<span>{fmtKm(row.completed)}</span>
|
||||
<span>/ {fmtKm(row.goal)} km</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(row.taskPerVehicle)} km</td>
|
||||
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{row.qualified}</td>
|
||||
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{row.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(row.goal)} km</td>
|
||||
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(row.completed)} km</td>
|
||||
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(row.remainingMileage)} km</td>
|
||||
<td className="px-3 py-3 text-xs text-slate-300 text-center">{row.days}</td>
|
||||
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">
|
||||
{row.assessment && row.days === 0 ? '考核已到期' : `${fmtKm(row.daily)} km`}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { motion } from 'motion/react';
|
||||
import type { TargetSummary } from '../types';
|
||||
import type { SelectedTargetMetrics } from './model';
|
||||
import { fmtKm, fmtPercent, shortTargetName } from './model';
|
||||
|
||||
interface StatisticsOverviewProps {
|
||||
targets: TargetSummary[];
|
||||
selectedTargetId: number | null;
|
||||
selectedTarget: TargetSummary | undefined;
|
||||
metrics: SelectedTargetMetrics;
|
||||
onSelectTarget: (targetId: number) => void;
|
||||
}
|
||||
|
||||
export default function StatisticsOverview({
|
||||
targets,
|
||||
selectedTargetId,
|
||||
selectedTarget,
|
||||
metrics,
|
||||
onSelectTarget,
|
||||
}: StatisticsOverviewProps) {
|
||||
return (
|
||||
<>
|
||||
<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={() => onSelectTarget(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>
|
||||
|
||||
{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 ${metrics.pressureLevel === '健康' ? 'text-emerald-600' : metrics.pressureLevel === '关注' ? 'text-amber-600' : 'text-rose-600'}`}>
|
||||
{metrics.pressureLevel}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">完成率 {fmtPercent(metrics.completion)}</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, metrics.remaining))}<span className="ml-1 text-[10px] text-slate-400">km</span></div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">剩余 {metrics.daysLeft} 天</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">
|
||||
{metrics.daysLeft > 0 ? (
|
||||
<>
|
||||
{fmtKm(metrics.dailyTarget)}<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 ${metrics.trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{metrics.trendDelta >= 0 ? '+' : ''}{fmtKm(metrics.trendDelta)}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">达标率 {fmtPercent(metrics.qualifiedRate)}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { ChevronDown, Maximize2, Truck } from 'lucide-react';
|
||||
import Blur from '../../../components/Blur';
|
||||
import type { TargetSummary, TargetVehicle } from '../types';
|
||||
import { fmtDateLabel, fmtKm, fmtPercent, getTargetAssessment } from './model';
|
||||
|
||||
interface TargetDetailPanelProps {
|
||||
target: TargetSummary | undefined;
|
||||
expandedTargetId: number | null;
|
||||
selectedYear: number | undefined;
|
||||
vehicles: TargetVehicle[];
|
||||
currentDateLabel: string;
|
||||
onOpenFullscreen: () => void;
|
||||
onExpandTarget: (targetId: number) => void;
|
||||
onAssessmentYearChange: (targetId: number, year: number) => void;
|
||||
onViewAll: (target: TargetSummary) => void;
|
||||
}
|
||||
|
||||
export default function TargetDetailPanel({
|
||||
target,
|
||||
expandedTargetId,
|
||||
selectedYear,
|
||||
vehicles,
|
||||
currentDateLabel,
|
||||
onOpenFullscreen,
|
||||
onExpandTarget,
|
||||
onAssessmentYearChange,
|
||||
onViewAll,
|
||||
}: TargetDetailPanelProps) {
|
||||
const assessment = target ? getTargetAssessment(target, selectedYear) : null;
|
||||
const primaryCompletion = assessment?.completionRate ?? target?.avgCompletion ?? 0;
|
||||
const primaryQualified = assessment?.qualifiedCount ?? target?.yearQualifiedCount ?? 0;
|
||||
const primaryQualifiedLabel = assessment ? `${assessment.label}达标:` : '达标:';
|
||||
|
||||
return (
|
||||
<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">{target?.targetName || '请选择车型'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenFullscreen}
|
||||
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">
|
||||
{target && (
|
||||
<div
|
||||
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={() => onExpandTarget(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={(event) => event.stopPropagation()}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation();
|
||||
onAssessmentYearChange(target.id, Number(event.target.value));
|
||||
}}
|
||||
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((period, index) => (
|
||||
<p key={index} 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">总考核里程</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, index) => (
|
||||
<p key={index} 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">
|
||||
数据截至 {assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel}
|
||||
</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>
|
||||
|
||||
<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={(event) => {
|
||||
event.stopPropagation();
|
||||
onViewAll(target);
|
||||
}}
|
||||
className="text-[8px] text-blue-500 font-bold hover:underline"
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{vehicles.slice(0, 5).map(vehicle => (
|
||||
<div key={vehicle.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>{vehicle.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">{vehicle.todayMileage}</span>
|
||||
<span className="text-[8px] text-slate-400 ml-1">KM</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
LabelList,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { TargetSummary, TrendPoint } from '../types';
|
||||
import { fmtKm, trendDateLabel, type ChartType } from './model';
|
||||
|
||||
interface TrendPanelProps {
|
||||
selectedTarget: TargetSummary | undefined;
|
||||
selectedCompletion: number;
|
||||
trendData: TrendPoint[];
|
||||
}
|
||||
|
||||
export default function TrendPanel({
|
||||
selectedTarget,
|
||||
selectedCompletion,
|
||||
trendData,
|
||||
}: TrendPanelProps) {
|
||||
const [chartType, setChartType] = useState<ChartType>('bar');
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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(selectedTarget?.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(selectedTarget?.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">
|
||||
{selectedTarget?.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={trendDateLabel} />
|
||||
<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={trendDateLabel} />
|
||||
<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={trendDateLabel} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment } from '../types';
|
||||
import {
|
||||
deriveSelectedTargetMetrics,
|
||||
deriveTargetTableRow,
|
||||
filterAndSortTargetVehicles,
|
||||
fmtDateLabel,
|
||||
fmtKm,
|
||||
fmtPercent,
|
||||
getCurrentDateLabel,
|
||||
getDefaultDate,
|
||||
getTargetAssessment,
|
||||
initialAssessmentYears,
|
||||
orderTargets,
|
||||
shortTargetName,
|
||||
summarizeTargets,
|
||||
totalTodayMileage,
|
||||
trendDateLabel,
|
||||
} from './model';
|
||||
|
||||
function assessment(overrides: Partial<TargetYearlyAssessment> = {}): TargetYearlyAssessment {
|
||||
return {
|
||||
yearNumber: 1,
|
||||
label: '考核第1年',
|
||||
vehicleCount: 8,
|
||||
target: 8000,
|
||||
completed: 6000,
|
||||
remaining: 2000,
|
||||
completionRate: 75,
|
||||
qualifiedCount: 4,
|
||||
qualifiedRate: 50,
|
||||
halfQualifiedCount: 6,
|
||||
daysLeft: 20,
|
||||
dailyTarget: 100,
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-12-31',
|
||||
periods: ['2026/1/1-2026/12/31'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function target(overrides: Partial<TargetSummary> = {}): TargetSummary {
|
||||
return {
|
||||
id: 1,
|
||||
targetName: '10辆4.5T普货',
|
||||
vehicleCount: 10,
|
||||
totalMileagePerVehicle: 2000,
|
||||
annualMileagePerVehicle: 1000,
|
||||
assessmentYears: 2,
|
||||
periods: ['2026/1/1-2027/12/31'],
|
||||
todayTotal: 300,
|
||||
cumulativeTotal: 5000,
|
||||
avgCompletion: 42,
|
||||
qualifiedCount: 3,
|
||||
yearQualifiedCount: 2,
|
||||
halfQualifiedCount: 5,
|
||||
currentYearTarget: 10000,
|
||||
currentYearCompleted: 4200,
|
||||
remaining: 5800,
|
||||
daysLeft: 50,
|
||||
dailyTarget: 116,
|
||||
firstYearVehicleCount: 10,
|
||||
firstYearTarget: 10000,
|
||||
firstYearCompleted: 4200,
|
||||
firstYearRemaining: 5800,
|
||||
firstYearCompletionRate: 42,
|
||||
firstYearQualifiedCount: 2,
|
||||
firstYearQualifiedRate: 20,
|
||||
firstYearHalfQualifiedCount: 5,
|
||||
firstYearDaysLeft: 50,
|
||||
firstYearDailyTarget: 116,
|
||||
firstYearStartDate: '2026-01-01',
|
||||
firstYearEndDate: '2026-12-31',
|
||||
yearlyAssessments: [assessment()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function vehicle(plateNumber: string, todayMileage: number): TargetVehicle {
|
||||
return {
|
||||
plateNumber,
|
||||
todayMileage,
|
||||
totalMileage: 1000,
|
||||
completionRate: 10,
|
||||
isQualified: false,
|
||||
currentYearIsQualified: false,
|
||||
dailyRequiredMileage: 100,
|
||||
rentStatus: null,
|
||||
department: null,
|
||||
customer: null,
|
||||
isOnline: false,
|
||||
};
|
||||
}
|
||||
|
||||
test('statistics dates retain the 05:00 rollover and current local date labels', () => {
|
||||
assert.equal(getDefaultDate(new Date(2026, 7, 13, 4, 59)), '2026-08-12');
|
||||
assert.equal(getDefaultDate(new Date(2026, 7, 13, 5, 0)), '2026-08-13');
|
||||
assert.equal(getCurrentDateLabel(new Date(2026, 7, 13, 23, 0)), '2026.8.13');
|
||||
assert.equal(fmtDateLabel('2026-08-03'), '2026.8.3');
|
||||
assert.equal(fmtDateLabel(null), '');
|
||||
});
|
||||
|
||||
test('statistics number, percent, target, and trend labels preserve display precision', () => {
|
||||
assert.equal(fmtKm(12345.67), '1.23万');
|
||||
assert.equal(fmtKm(9999.5), '9,999.5');
|
||||
assert.equal(fmtKm(-12000), '-12,000');
|
||||
assert.equal(fmtPercent(12.34), '12.3%');
|
||||
assert.equal(shortTargetName('10辆4.5T普货'), '10台普货');
|
||||
assert.equal(shortTargetName('8台4.5T冷链车'), '8台冷藏车');
|
||||
assert.equal(shortTargetName('未命名批次'), '未命名批次');
|
||||
assert.equal(trendDateLabel('08-03'), '8.3');
|
||||
});
|
||||
|
||||
test('target ordering and initial assessment years retain first-match fallbacks', () => {
|
||||
const plain = target({ id: 1, targetName: '普通批次' });
|
||||
const focused = target({ id: 2, targetName: '羚牛136台普货', yearlyAssessments: [] });
|
||||
assert.deepEqual(orderTargets([plain, focused]).map(item => item.id), [2, 1]);
|
||||
assert.deepEqual(initialAssessmentYears([plain, focused]), { 1: 1, 2: 1 });
|
||||
assert.equal(getTargetAssessment(plain, 99)?.yearNumber, 1);
|
||||
assert.equal(getTargetAssessment(focused, 1), null);
|
||||
});
|
||||
|
||||
test('selected metrics retain assessment priority, trend delta, and pressure thresholds', () => {
|
||||
const secondYear = assessment({
|
||||
yearNumber: 2,
|
||||
label: '考核第2年',
|
||||
completionRate: 92,
|
||||
remaining: -50,
|
||||
daysLeft: 0,
|
||||
dailyTarget: 7,
|
||||
qualifiedRate: 80,
|
||||
});
|
||||
const metrics = deriveSelectedTargetMetrics(
|
||||
target({ yearlyAssessments: [assessment(), secondYear] }),
|
||||
2,
|
||||
[{ date: '08-01', mileage: 500 }, { date: '08-02', mileage: 420 }],
|
||||
);
|
||||
assert.deepEqual(metrics, {
|
||||
assessment: secondYear,
|
||||
completion: 92,
|
||||
remaining: -50,
|
||||
daysLeft: 0,
|
||||
dailyTarget: 7,
|
||||
qualifiedRate: 80,
|
||||
trendDelta: -80,
|
||||
pressureLevel: '健康',
|
||||
});
|
||||
|
||||
const fallback = deriveSelectedTargetMetrics(target({ yearlyAssessments: [] }), undefined, []);
|
||||
assert.equal(fallback.completion, 42);
|
||||
assert.equal(fallback.dailyTarget, 0);
|
||||
assert.equal(fallback.qualifiedRate, 20);
|
||||
assert.equal(fallback.pressureLevel, '高压');
|
||||
});
|
||||
|
||||
test('table rows and fullscreen totals preserve unweighted selected-year calculations', () => {
|
||||
const first = target({ id: 1, todayTotal: 100, cumulativeTotal: 1000 });
|
||||
const second = target({
|
||||
id: 2,
|
||||
vehicleCount: 30,
|
||||
todayTotal: 200,
|
||||
cumulativeTotal: 3000,
|
||||
yearlyAssessments: [assessment({ yearNumber: 2, completionRate: 95 })],
|
||||
});
|
||||
const row = deriveTargetTableRow(second, 2);
|
||||
assert.equal(row.completion, 95);
|
||||
assert.equal(row.taskPerVehicle, 2000);
|
||||
assert.equal(row.goal, 8000);
|
||||
assert.deepEqual(summarizeTargets([first, second], { 1: 1, 2: 2 }), {
|
||||
today: 300,
|
||||
cumulative: 4000,
|
||||
vehicleCount: 40,
|
||||
averageCompletion: 85,
|
||||
});
|
||||
assert.equal(summarizeTargets([], {}).averageCompletion, 0);
|
||||
});
|
||||
|
||||
test('vehicle search and mileage sorting preserve case-insensitive filtering and zero fallback', () => {
|
||||
const vehicles = [
|
||||
vehicle('YUE-A2', 20),
|
||||
vehicle('yue-A1', 0),
|
||||
vehicle('OTHER', 100),
|
||||
];
|
||||
assert.deepEqual(
|
||||
filterAndSortTargetVehicles(vehicles, 'yue', 'desc').map(item => item.plateNumber),
|
||||
['YUE-A2', 'yue-A1'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterAndSortTargetVehicles(vehicles, 'YUE', 'asc').map(item => item.plateNumber),
|
||||
['yue-A1', 'YUE-A2'],
|
||||
);
|
||||
assert.equal(totalTodayMileage(vehicles), 120);
|
||||
assert.deepEqual(vehicles.map(item => item.plateNumber), ['YUE-A2', 'yue-A1', 'OTHER']);
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from '../types';
|
||||
|
||||
export type ChartType = 'bar' | 'line' | 'area';
|
||||
export type VehicleSort = 'asc' | 'desc';
|
||||
export type PressureLevel = '健康' | '关注' | '高压';
|
||||
|
||||
export interface SelectedTargetMetrics {
|
||||
assessment: TargetYearlyAssessment | null;
|
||||
completion: number;
|
||||
remaining: number;
|
||||
daysLeft: number;
|
||||
dailyTarget: number;
|
||||
qualifiedRate: number;
|
||||
trendDelta: number;
|
||||
pressureLevel: PressureLevel;
|
||||
}
|
||||
|
||||
export interface TargetTableRow {
|
||||
assessment: TargetYearlyAssessment | null;
|
||||
completion: number;
|
||||
qualified: number;
|
||||
halfQualified: number;
|
||||
goal: number;
|
||||
completed: number;
|
||||
remainingMileage: number;
|
||||
days: number;
|
||||
daily: number;
|
||||
taskPerVehicle: number;
|
||||
}
|
||||
|
||||
export interface TargetTotals {
|
||||
today: number;
|
||||
cumulative: number;
|
||||
vehicleCount: number;
|
||||
averageCompletion: number;
|
||||
}
|
||||
|
||||
export function getDefaultDate(now = new Date()): string {
|
||||
const date = new Date(now);
|
||||
if (date.getHours() < 5) date.setDate(date.getDate() - 1);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function getCurrentDateLabel(now = new Date()): string {
|
||||
return `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`;
|
||||
}
|
||||
|
||||
export function fmtKm(value: number): string {
|
||||
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
export function fmtPercent(value: number): string {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export 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];
|
||||
}
|
||||
|
||||
export function fmtDateLabel(date: string | null): string {
|
||||
if (!date) return '';
|
||||
const [year, month, day] = date.split('-');
|
||||
return `${year}.${Number(month)}.${Number(day)}`;
|
||||
}
|
||||
|
||||
export function shortTargetName(name: string): string {
|
||||
const match = name.match(/(\d+)[辆台](.+)/);
|
||||
if (!match) return name;
|
||||
const count = match[1];
|
||||
let desc = match[2];
|
||||
desc = desc.replace('4.5T普货', '普货');
|
||||
desc = desc.replace('4.5T冷链车', '冷藏车');
|
||||
desc = desc.replace('4.5T冷链', '冷藏车');
|
||||
desc = desc.replace('18T', '18T');
|
||||
return `${count}台${desc}`;
|
||||
}
|
||||
|
||||
export function trendDateLabel(value: string): string {
|
||||
const [month, day] = value.split('-');
|
||||
return `${parseInt(month)}.${parseInt(day)}`;
|
||||
}
|
||||
|
||||
export function orderTargets(targets: TargetSummary[]): TargetSummary[] {
|
||||
const focused = targets.find(item => item.targetName.includes('羚牛136')) || targets[0];
|
||||
return focused
|
||||
? [focused, ...targets.filter(item => item.id !== focused.id)]
|
||||
: targets;
|
||||
}
|
||||
|
||||
export function initialAssessmentYears(targets: TargetSummary[]): Record<number, number> {
|
||||
return Object.fromEntries(
|
||||
targets.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1]),
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveSelectedTargetMetrics(
|
||||
target: TargetSummary | undefined,
|
||||
selectedYear: number | undefined,
|
||||
trendData: TrendPoint[],
|
||||
): SelectedTargetMetrics {
|
||||
const assessment = target ? getTargetAssessment(target, selectedYear) : null;
|
||||
const completion = assessment?.completionRate ?? target?.avgCompletion ?? 0;
|
||||
const remaining = assessment?.remaining ?? 0;
|
||||
const daysLeft = assessment?.daysLeft ?? target?.daysLeft ?? 0;
|
||||
const dailyTarget = assessment?.dailyTarget ?? (daysLeft > 0 ? remaining / daysLeft : 0);
|
||||
const qualifiedRate = assessment?.qualifiedRate
|
||||
?? (target?.vehicleCount ? target.yearQualifiedCount / target.vehicleCount * 100 : 0);
|
||||
const latestTrend = trendData[trendData.length - 1];
|
||||
const previousTrend = trendData[trendData.length - 2];
|
||||
const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0;
|
||||
const pressureLevel = completion >= 90 ? '健康' : completion >= 70 ? '关注' : '高压';
|
||||
|
||||
return {
|
||||
assessment,
|
||||
completion,
|
||||
remaining,
|
||||
daysLeft,
|
||||
dailyTarget,
|
||||
qualifiedRate,
|
||||
trendDelta,
|
||||
pressureLevel,
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveTargetTableRow(
|
||||
target: TargetSummary,
|
||||
selectedYear?: number,
|
||||
): TargetTableRow {
|
||||
const assessment = getTargetAssessment(target, selectedYear);
|
||||
return {
|
||||
assessment,
|
||||
completion: assessment?.completionRate ?? target.avgCompletion,
|
||||
qualified: assessment?.qualifiedCount ?? target.yearQualifiedCount,
|
||||
halfQualified: assessment?.halfQualifiedCount ?? target.halfQualifiedCount,
|
||||
goal: assessment?.target ?? target.currentYearTarget,
|
||||
completed: assessment?.completed ?? target.currentYearCompleted,
|
||||
remainingMileage: assessment?.remaining ?? target.remaining,
|
||||
days: assessment?.daysLeft ?? target.daysLeft,
|
||||
daily: assessment?.dailyTarget ?? target.dailyTarget,
|
||||
taskPerVehicle: target.annualMileagePerVehicle * (assessment?.yearNumber || 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeTargets(
|
||||
targets: TargetSummary[],
|
||||
assessmentYearMap: Record<number, number>,
|
||||
): TargetTotals {
|
||||
const totals = targets.reduce((result, target) => ({
|
||||
today: result.today + target.todayTotal,
|
||||
cumulative: result.cumulative + target.cumulativeTotal,
|
||||
vehicleCount: result.vehicleCount + target.vehicleCount,
|
||||
completion: result.completion
|
||||
+ (getTargetAssessment(target, assessmentYearMap[target.id])?.completionRate ?? target.avgCompletion),
|
||||
}), { today: 0, cumulative: 0, vehicleCount: 0, completion: 0 });
|
||||
|
||||
return {
|
||||
today: totals.today,
|
||||
cumulative: totals.cumulative,
|
||||
vehicleCount: totals.vehicleCount,
|
||||
averageCompletion: targets.length > 0 ? totals.completion / targets.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function filterAndSortTargetVehicles(
|
||||
vehicles: TargetVehicle[],
|
||||
search: string,
|
||||
sort: VehicleSort,
|
||||
): TargetVehicle[] {
|
||||
return vehicles
|
||||
.filter(vehicle => vehicle.plateNumber.toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
const valueA = a.todayMileage || 0;
|
||||
const valueB = b.todayMileage || 0;
|
||||
return sort === 'desc' ? valueB - valueA : valueA - valueB;
|
||||
});
|
||||
}
|
||||
|
||||
export function totalTodayMileage(vehicles: TargetVehicle[]): number {
|
||||
return vehicles.reduce((sum, vehicle) => sum + (vehicle.todayMileage || 0), 0);
|
||||
}
|
||||
Reference in New Issue
Block a user