refactor: modularize application domains

This commit is contained in:
kkfluous
2026-08-13 12:03:34 +08:00
parent d610e4b841
commit 06fe75b4c7
142 changed files with 14645 additions and 8885 deletions
+69 -317
View File
@@ -1,43 +1,31 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Activity, AlertTriangle, CheckCircle2, Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download, SendHorizonal } from 'lucide-react';
import { Activity, RotateCcw, Search, ChevronDown } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchSuggestions, sendNotifyBatch } from './api';
import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types';
import type { SchedulingResponse, SchedulingSuggestion } from './types';
import SuggestionList from './SuggestionList';
import SuggestionDetail from './SuggestionDetail';
import NotificationHistory from './NotificationHistory';
import { exportSuggestionsCsv } from './csv-export';
import Blur from '../../components/Blur';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { MetricTile, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
type TypeFilter = 'all' | 'qualified' | 'hopeless';
interface AdvancedFilters {
plateSearch: string;
region: string;
vehicleType: string;
customer: string;
department: string;
manager: string;
}
const EMPTY_FILTERS: AdvancedFilters = { plateSearch: '', region: '', vehicleType: '', customer: '', department: '', manager: '' };
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冷链', '冷藏车');
return `${count}${desc}`;
}
function hasActiveFilters(f: AdvancedFilters): boolean {
return f.plateSearch !== '' || f.region !== '' || f.vehicleType !== '' || f.customer !== '';
}
import { PageFrame } from '../../components/ui/surface';
import ActiveFilterTags from './scheduling-module/ActiveFilterTags';
import BatchActionBar from './scheduling-module/BatchActionBar';
import BatchConfirmModal from './scheduling-module/BatchConfirmModal';
import ListHeader from './scheduling-module/ListHeader';
import ListLoadingSkeleton from './scheduling-module/ListLoadingSkeleton';
import SchedulingSkeleton from './scheduling-module/SchedulingSkeleton';
import SummaryCards from './scheduling-module/SummaryCards';
import {
EMPTY_FILTERS,
buildBatchItems,
buildFilterOptions,
countActiveFilters,
filterSuggestions,
hasResettableFilters,
type AdvancedFilters,
type TypeFilter,
} from './scheduling-module/model';
function FilterSelect({ label, options, value, onChange, placeholder }: {
label: string; options: string[]; value: string; onChange: (v: string) => void; placeholder: string;
@@ -88,56 +76,6 @@ function FilterSelect({ label, options, value, onChange, placeholder }: {
);
}
function Sk({ className }: { className?: string }) {
return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />;
}
function SkeletonPage() {
return (
<PageFrame
title="智能调度工作台"
subtitle="自动识别高里程可释放车辆与低里程待救援车辆,形成可登记、可追踪的运营干预建议。"
icon={Activity}
eyebrow="SCHEDULING OPS"
meta="建议生成中 · 正在计算候选车辆"
>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => <SkeletonBlock key={i} className="h-28" />)}
</div>
<SurfaceCard>
<div className="space-y-3 p-4">
<SkeletonBlock className="h-5 w-40" />
<div className="flex gap-2">
{[0, 1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-24 rounded-full" />)}
</div>
<div className="divide-y divide-slate-50">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-3">
<SkeletonBlock className="h-10 w-1 rounded-full" />
<div className="flex-1 space-y-2">
<SkeletonBlock className="h-3.5 w-48" />
<SkeletonBlock className="h-2.5 w-72 max-w-full" />
</div>
<SkeletonBlock className="h-6 w-16" />
</div>
))}
</div>
</div>
</SurfaceCard>
</PageFrame>
);
}
function pickBestCandidate(s: SchedulingSuggestion): CandidateVehicle | null {
// Business rule: at most one active intervention per suggestion. If ANY
// candidate is already intervened, skip the whole suggestion in batch flow.
const hasActive = s.candidates.some(
c => c.notificationStatus === 'sent' || c.notificationStatus === 'executed',
);
if (hasActive) return null;
return s.candidates.find(c => c.canQualifyAfterSwap) ?? s.candidates[0] ?? null;
}
export default function SchedulingModule() {
const [data, setData] = useState<SchedulingResponse | null>(null);
const [loading, setLoading] = useState(false);
@@ -187,16 +125,7 @@ export default function SchedulingModule() {
}, []);
const batchItems = useMemo(() => {
if (!data) return [];
return [...selectedIds]
.map(id => data.suggestions.find(s => s.id === id))
.filter((s): s is SchedulingSuggestion => !!s)
.map(s => {
const candidate = pickBestCandidate(s);
if (!candidate) return null;
return { suggestion: s, candidate };
})
.filter((x): x is { suggestion: SchedulingSuggestion; candidate: CandidateVehicle } => !!x);
return buildBatchItems(data, selectedIds);
}, [data, selectedIds]);
const handleBatchSubmit = useCallback(async () => {
@@ -222,38 +151,18 @@ export default function SchedulingModule() {
}, [batchItems, loadData, exitSelectMode]);
const filterOptions = useMemo(() => {
if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] };
const r = new Set<string>(), t = new Set<string>(), c = new Set<string>(), d = new Set<string>(), m = new Set<string>();
for (const s of data.suggestions) {
const v = s.currentVehicle;
if (v.region) r.add(v.region);
if (v.vehicleType) t.add(v.vehicleType);
if (v.customer) c.add(v.customer);
if (v.department) d.add(v.department);
if (v.manager) m.add(v.manager);
}
return { regions: [...r].sort(), vehicleTypes: [...t].sort(), customers: [...c].sort(), departments: [...d].sort(), managers: [...m].sort() };
return buildFilterOptions(data);
}, [data]);
const filteredSuggestions = useMemo(() => {
if (!data) return [];
let list = data.suggestions;
if (typeFilter === 'qualified') list = list.filter(s => s.type === 'replace_qualified');
if (typeFilter === 'hopeless') list = list.filter(s => s.type === 'rescue_hopeless');
if (filters.plateSearch) { const q = filters.plateSearch.toLowerCase(); list = list.filter(s => s.currentVehicle.plateNumber.toLowerCase().includes(q)); }
if (filters.region) list = list.filter(s => s.currentVehicle.region === filters.region);
if (filters.vehicleType) list = list.filter(s => s.currentVehicle.vehicleType === filters.vehicleType);
if (filters.customer) list = list.filter(s => s.currentVehicle.customer === filters.customer);
if (filters.department) list = list.filter(s => s.currentVehicle.department === filters.department);
if (filters.manager) list = list.filter(s => s.currentVehicle.manager === filters.manager);
return list;
return filterSuggestions(data, typeFilter, filters);
}, [data, typeFilter, filters]);
const summary = data?.summary;
const activeFilterCount = [filters.plateSearch, filters.region, filters.vehicleType, filters.customer, filters.department, filters.manager].filter(Boolean).length;
const activeFilterCount = countActiveFilters(filters);
// Initial load — full page skeleton
if (loading && !data) return <SkeletonPage />;
if (loading && !data) return <SchedulingSkeleton />;
return (
<PageFrame
@@ -271,95 +180,36 @@ export default function SchedulingModule() {
>
{/* ===== Summary Cards ===== */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<button type="button" onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')} className={typeFilter === 'qualified' ? 'rounded-2xl ring-2 ring-orange-400 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
<MetricTile icon={CheckCircle2} label="已完成考核目标" value={summary?.qualifiedCount ?? 0} unit="台" helper="换下,腾位给待达标车" tone="amber" />
</button>
<button type="button" onClick={() => setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')} className={typeFilter === 'hopeless' ? 'rounded-2xl ring-2 ring-blue-500 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
<MetricTile icon={AlertTriangle} label="预估无法达标" value={summary?.hopelessCount ?? 0} unit="台" helper="换走,换上快达标的车" tone="blue" />
</button>
<button type="button" onClick={() => setTypeFilter('all')} className={typeFilter === 'all' ? 'rounded-2xl ring-2 ring-slate-700 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
<MetricTile icon={Activity} label="替换建议" value={summary?.suggestionCount ?? 0} unit="条" helper={`执行后预计 +${summary?.estimatedGain ?? 0} 台达标`} tone="slate" />
</button>
<button type="button" onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }} className="rounded-2xl">
<MetricTile icon={SendHorizonal} label="近期已干预" value={summary?.recentInterventionCount ?? 0} unit="条" helper="最近 7 天 · 点击查看" tone="emerald" />
</button>
</div>
<SummaryCards
summary={summary}
typeFilter={typeFilter}
onTypeFilterChange={setTypeFilter}
onShowRecentHistory={() => { setShowHistory(true); setHistoryRecentOnly(true); }}
/>
{/* ===== List Card ===== */}
<div className="bg-white rounded-2xl border border-slate-200/60 shadow-sm overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-slate-100">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-bold text-slate-900"></h3>
<div className="flex items-center gap-1">
<button onClick={loadData} disabled={loading}
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer">
<RotateCcw size={15} className={loading ? 'animate-spin' : ''} />
</button>
<button
onClick={() => exportSuggestionsCsv(filteredSuggestions)}
disabled={filteredSuggestions.length === 0}
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
title="导出 CSV"
>
<Download size={15} />
</button>
<button
onClick={() => { setShowHistory(true); setHistoryRecentOnly(false); }}
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer"
title="调度记录"
>
<Clock size={15} />
</button>
<button
onClick={() => {
if (selectMode) exitSelectMode();
else { setSelectMode(true); setSelectedSuggestion(null); }
}}
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
selectMode ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
}`}
title={selectMode ? '退出多选' : '多选模式'}
>
<CheckSquare size={15} />
</button>
<button
onClick={() => { setShowFilter(!showFilter); setTempFilters(filters); }}
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
showFilter || activeFilterCount > 0 ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
}`}
>
<Filter size={15} />
{activeFilterCount > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-600 text-white text-[8px] font-bold rounded-full flex items-center justify-center">{activeFilterCount}</span>
)}
</button>
</div>
</div>
<div className="flex gap-2 overflow-x-auto no-scrollbar">
<button
onClick={() => { setSelectedTargetId(undefined); setTypeFilter('all'); }}
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
selectedTargetId === undefined ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
</button>
{data?.targets.map(t => (
<button key={t.id}
onClick={() => { setSelectedTargetId(t.id); setTypeFilter('all'); }}
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
selectedTargetId === t.id ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
{shortTargetName(t.name)}
</button>
))}
</div>
</div>
<ListHeader
loading={loading}
targets={data?.targets}
selectedTargetId={selectedTargetId}
selectMode={selectMode}
showFilter={showFilter}
activeFilterCount={activeFilterCount}
canExport={filteredSuggestions.length > 0}
onRefresh={loadData}
onExport={() => exportSuggestionsCsv(filteredSuggestions)}
onShowHistory={() => { setShowHistory(true); setHistoryRecentOnly(false); }}
onToggleSelectMode={() => {
if (selectMode) exitSelectMode();
else { setSelectMode(true); setSelectedSuggestion(null); }
}}
onToggleFilter={() => { setShowFilter(!showFilter); setTempFilters(filters); }}
onSelectAll={() => { setSelectedTargetId(undefined); setTypeFilter('all'); }}
onSelectTarget={targetId => { setSelectedTargetId(targetId); setTypeFilter('all'); }}
/>
{/* Filter Panel */}
<AnimatePresence>
@@ -368,7 +218,7 @@ export default function SchedulingModule() {
<div className="px-4 py-4 bg-slate-50/60 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-700"></span>
{hasActiveFilters(tempFilters) && (
{hasResettableFilters(tempFilters) && (
<button onClick={() => setTempFilters(EMPTY_FILTERS)} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer"></button>
)}
</div>
@@ -400,16 +250,11 @@ export default function SchedulingModule() {
{/* Active filter tags */}
{activeFilterCount > 0 && !showFilter && (
<div className="px-4 py-2 border-b border-slate-100 flex items-center gap-2 flex-wrap">
<span className="text-[10px] text-slate-400">:</span>
{filters.plateSearch && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1"> "{filters.plateSearch}" <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, plateSearch: '' }))} /></span>}
{filters.region && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.region} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, region: '' }))} /></span>}
{filters.vehicleType && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.vehicleType} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, vehicleType: '' }))} /></span>}
{filters.department && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.department} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, department: '' }))} /></span>}
{filters.manager && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.manager} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, manager: '' }))} /></span>}
{filters.customer && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.customer} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, customer: '' }))} /></span>}
<button onClick={() => setFilters(EMPTY_FILTERS)} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer"></button>
</div>
<ActiveFilterTags
filters={filters}
onChange={setFilters}
onClear={() => setFilters(EMPTY_FILTERS)}
/>
)}
{(activeFilterCount > 0 || typeFilter !== 'all') && (
@@ -418,26 +263,7 @@ export default function SchedulingModule() {
{loading ? (
/* List skeleton while refreshing */
<div className="divide-y divide-slate-50">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="px-4 py-3 flex items-center gap-3">
<Sk className="w-1 h-10 rounded-full" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Sk className="h-3.5 w-20" />
<Sk className="h-3 w-10 rounded-full" />
<Sk className="h-3 w-14" />
</div>
<div className="flex items-center gap-3">
<Sk className="h-2.5 w-28" />
<Sk className="h-2.5 w-16" />
<Sk className="h-2.5 w-14" />
</div>
</div>
<Sk className="h-4 w-8" />
</div>
))}
</div>
<ListLoadingSkeleton />
) : (
<SuggestionList
suggestions={filteredSuggestions}
@@ -465,97 +291,23 @@ export default function SchedulingModule() {
{/* Batch action bar */}
<AnimatePresence>
{selectMode && (
<motion.div
initial={{ y: 80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 80, opacity: 0 }}
className="fixed bottom-4 left-3 right-3 md:left-auto md:right-6 md:bottom-6 md:w-[360px] z-40 bg-slate-900 text-white rounded-2xl shadow-2xl px-4 py-3 flex items-center justify-between gap-3"
>
<div className="flex items-center gap-2">
<span className="text-xs font-medium"></span>
<span className="text-lg font-black">{selectedIds.size}</span>
<span className="text-xs text-slate-400"></span>
</div>
<div className="flex items-center gap-2">
<button
onClick={exitSelectMode}
className="text-xs font-medium text-slate-300 hover:text-white px-2 py-1.5 cursor-pointer transition-colors"
>
</button>
<button
onClick={() => setShowBatchConfirm(true)}
disabled={selectedIds.size === 0}
className="flex items-center gap-1.5 text-xs font-bold bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-400 text-white px-3 py-1.5 rounded-lg cursor-pointer disabled:cursor-not-allowed transition-colors"
>
<Send size={12} />
</button>
</div>
</motion.div>
<BatchActionBar
selectedCount={selectedIds.size}
onCancel={exitSelectMode}
onConfirm={() => setShowBatchConfirm(true)}
/>
)}
</AnimatePresence>
{/* Batch confirmation modal */}
{showBatchConfirm && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[70] flex items-end sm:items-center justify-center" onClick={() => !batchInFlight && setShowBatchConfirm(false)}>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
onClick={e => e.stopPropagation()}
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-md overflow-hidden flex flex-col max-h-[80vh] sm:mx-4"
>
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
<span className="text-white font-bold text-sm"></span>
<button
onClick={() => !batchInFlight && setShowBatchConfirm(false)}
disabled={batchInFlight}
className="text-slate-400 hover:text-white transition-colors p-1 cursor-pointer disabled:opacity-50"
>
<X size={18} />
</button>
</div>
<div className="px-4 py-3 overflow-y-auto flex-1">
<p className="text-xs text-slate-500 mb-3">
<span className="font-bold text-slate-800">{batchItems.length}</span>
</p>
<div className="space-y-2">
{batchItems.map(({ suggestion, candidate }) => (
<div key={suggestion.id} className="text-[11px] bg-slate-50 rounded-lg px-3 py-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<span className="font-mono font-bold text-slate-900"><Blur>{suggestion.currentVehicle.plateNumber}</Blur></span>
<span className="text-slate-400"></span>
<span className="font-mono font-bold text-blue-700"><Blur>{candidate.plateNumber}</Blur></span>
</div>
{candidate.canQualifyAfterSwap ? (
<span className="text-emerald-600 text-[9px] font-bold flex-shrink-0"></span>
) : (
<span className="text-amber-500 text-[9px] font-bold flex-shrink-0"></span>
)}
</div>
))}
</div>
{batchResultMsg && (
<p className="mt-3 text-[11px] text-slate-500">{batchResultMsg}</p>
)}
</div>
<div className="border-t border-slate-100 px-4 py-3 flex-shrink-0 flex gap-2">
<button
onClick={() => setShowBatchConfirm(false)}
disabled={batchInFlight}
className="flex-1 py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
>
</button>
<button
onClick={handleBatchSubmit}
disabled={batchInFlight || batchItems.length === 0}
className="flex-1 py-2 text-xs font-bold text-white bg-blue-600 hover:bg-blue-500 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
>
{batchInFlight ? '登记中...' : `确认登记 ${batchItems.length}`}
</button>
</div>
</motion.div>
</div>
<BatchConfirmModal
batchItems={batchItems}
batchInFlight={batchInFlight}
batchResultMsg={batchResultMsg}
onClose={() => setShowBatchConfirm(false)}
onSubmit={handleBatchSubmit}
/>
)}
<RotatingFooterHint className="pb-4" />
</PageFrame>
@@ -0,0 +1,24 @@
import type { Dispatch, SetStateAction } from 'react';
import { X } from 'lucide-react';
import type { AdvancedFilters } from './model';
interface ActiveFilterTagsProps {
filters: AdvancedFilters;
onChange: Dispatch<SetStateAction<AdvancedFilters>>;
onClear: () => void;
}
export default function ActiveFilterTags({ filters, onChange, onClear }: ActiveFilterTagsProps) {
return (
<div className="px-4 py-2 border-b border-slate-100 flex items-center gap-2 flex-wrap">
<span className="text-[10px] text-slate-400">:</span>
{filters.plateSearch && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1"> "{filters.plateSearch}" <X size={10} className="cursor-pointer" onClick={() => onChange(prev => ({ ...prev, plateSearch: '' }))} /></span>}
{filters.region && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.region} <X size={10} className="cursor-pointer" onClick={() => onChange(prev => ({ ...prev, region: '' }))} /></span>}
{filters.vehicleType && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.vehicleType} <X size={10} className="cursor-pointer" onClick={() => onChange(prev => ({ ...prev, vehicleType: '' }))} /></span>}
{filters.department && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.department} <X size={10} className="cursor-pointer" onClick={() => onChange(prev => ({ ...prev, department: '' }))} /></span>}
{filters.manager && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.manager} <X size={10} className="cursor-pointer" onClick={() => onChange(prev => ({ ...prev, manager: '' }))} /></span>}
{filters.customer && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.customer} <X size={10} className="cursor-pointer" onClick={() => onChange(prev => ({ ...prev, customer: '' }))} /></span>}
<button onClick={onClear} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer"></button>
</div>
);
}
@@ -0,0 +1,40 @@
import { Send } from 'lucide-react';
import { motion } from 'motion/react';
interface BatchActionBarProps {
selectedCount: number;
onCancel: () => void;
onConfirm: () => void;
}
export default function BatchActionBar({ selectedCount, onCancel, onConfirm }: BatchActionBarProps) {
return (
<motion.div
initial={{ y: 80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 80, opacity: 0 }}
className="fixed bottom-4 left-3 right-3 md:left-auto md:right-6 md:bottom-6 md:w-[360px] z-40 bg-slate-900 text-white rounded-2xl shadow-2xl px-4 py-3 flex items-center justify-between gap-3"
>
<div className="flex items-center gap-2">
<span className="text-xs font-medium"></span>
<span className="text-lg font-black">{selectedCount}</span>
<span className="text-xs text-slate-400"></span>
</div>
<div className="flex items-center gap-2">
<button
onClick={onCancel}
className="text-xs font-medium text-slate-300 hover:text-white px-2 py-1.5 cursor-pointer transition-colors"
>
</button>
<button
onClick={onConfirm}
disabled={selectedCount === 0}
className="flex items-center gap-1.5 text-xs font-bold bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-400 text-white px-3 py-1.5 rounded-lg cursor-pointer disabled:cursor-not-allowed transition-colors"
>
<Send size={12} />
</button>
</div>
</motion.div>
);
}
@@ -0,0 +1,82 @@
import { X } from 'lucide-react';
import { motion } from 'motion/react';
import Blur from '../../../components/Blur';
import type { BatchItem } from './model';
interface BatchConfirmModalProps {
batchItems: BatchItem[];
batchInFlight: boolean;
batchResultMsg: string | null;
onClose: () => void;
onSubmit: () => void;
}
export default function BatchConfirmModal({
batchItems,
batchInFlight,
batchResultMsg,
onClose,
onSubmit,
}: BatchConfirmModalProps) {
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[70] flex items-end sm:items-center justify-center" onClick={() => !batchInFlight && onClose()}>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
onClick={e => e.stopPropagation()}
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-md overflow-hidden flex flex-col max-h-[80vh] sm:mx-4"
>
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
<span className="text-white font-bold text-sm"></span>
<button
onClick={() => !batchInFlight && onClose()}
disabled={batchInFlight}
className="text-slate-400 hover:text-white transition-colors p-1 cursor-pointer disabled:opacity-50"
>
<X size={18} />
</button>
</div>
<div className="px-4 py-3 overflow-y-auto flex-1">
<p className="text-xs text-slate-500 mb-3">
<span className="font-bold text-slate-800">{batchItems.length}</span>
</p>
<div className="space-y-2">
{batchItems.map(({ suggestion, candidate }) => (
<div key={suggestion.id} className="text-[11px] bg-slate-50 rounded-lg px-3 py-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<span className="font-mono font-bold text-slate-900"><Blur>{suggestion.currentVehicle.plateNumber}</Blur></span>
<span className="text-slate-400"></span>
<span className="font-mono font-bold text-blue-700"><Blur>{candidate.plateNumber}</Blur></span>
</div>
{candidate.canQualifyAfterSwap ? (
<span className="text-emerald-600 text-[9px] font-bold flex-shrink-0"></span>
) : (
<span className="text-amber-500 text-[9px] font-bold flex-shrink-0"></span>
)}
</div>
))}
</div>
{batchResultMsg && (
<p className="mt-3 text-[11px] text-slate-500">{batchResultMsg}</p>
)}
</div>
<div className="border-t border-slate-100 px-4 py-3 flex-shrink-0 flex gap-2">
<button
onClick={onClose}
disabled={batchInFlight}
className="flex-1 py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
>
</button>
<button
onClick={onSubmit}
disabled={batchInFlight || batchItems.length === 0}
className="flex-1 py-2 text-xs font-bold text-white bg-blue-600 hover:bg-blue-500 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
>
{batchInFlight ? '登记中...' : `确认登记 ${batchItems.length}`}
</button>
</div>
</motion.div>
</div>
);
}
@@ -0,0 +1,107 @@
import { CheckSquare, Clock, Download, Filter, RotateCcw } from 'lucide-react';
import type { SchedulingTargetOption } from '../types';
import { shortTargetName } from './model';
interface ListHeaderProps {
loading: boolean;
targets: SchedulingTargetOption[] | undefined;
selectedTargetId: number | undefined;
selectMode: boolean;
showFilter: boolean;
activeFilterCount: number;
canExport: boolean;
onRefresh: () => void;
onExport: () => void;
onShowHistory: () => void;
onToggleSelectMode: () => void;
onToggleFilter: () => void;
onSelectAll: () => void;
onSelectTarget: (targetId: number) => void;
}
export default function ListHeader({
loading,
targets,
selectedTargetId,
selectMode,
showFilter,
activeFilterCount,
canExport,
onRefresh,
onExport,
onShowHistory,
onToggleSelectMode,
onToggleFilter,
onSelectAll,
onSelectTarget,
}: ListHeaderProps) {
return (
<div className="px-4 py-3 border-b border-slate-100">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-bold text-slate-900"></h3>
<div className="flex items-center gap-1">
<button onClick={onRefresh} disabled={loading}
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer">
<RotateCcw size={15} className={loading ? 'animate-spin' : ''} />
</button>
<button
onClick={onExport}
disabled={!canExport}
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
title="导出 CSV"
>
<Download size={15} />
</button>
<button
onClick={onShowHistory}
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer"
title="调度记录"
>
<Clock size={15} />
</button>
<button
onClick={onToggleSelectMode}
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
selectMode ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
}`}
title={selectMode ? '退出多选' : '多选模式'}
>
<CheckSquare size={15} />
</button>
<button
onClick={onToggleFilter}
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
showFilter || activeFilterCount > 0 ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
}`}
>
<Filter size={15} />
{activeFilterCount > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-600 text-white text-[8px] font-bold rounded-full flex items-center justify-center">{activeFilterCount}</span>
)}
</button>
</div>
</div>
<div className="flex gap-2 overflow-x-auto no-scrollbar">
<button
onClick={onSelectAll}
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
selectedTargetId === undefined ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
</button>
{targets?.map(target => (
<button key={target.id}
onClick={() => onSelectTarget(target.id)}
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
selectedTargetId === target.id ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
{shortTargetName(target.name)}
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,28 @@
function Sk({ className }: { className?: string }) {
return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />;
}
export default function ListLoadingSkeleton() {
return (
<div className="divide-y divide-slate-50">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="px-4 py-3 flex items-center gap-3">
<Sk className="w-1 h-10 rounded-full" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Sk className="h-3.5 w-20" />
<Sk className="h-3 w-10 rounded-full" />
<Sk className="h-3 w-14" />
</div>
<div className="flex items-center gap-3">
<Sk className="h-2.5 w-28" />
<Sk className="h-2.5 w-16" />
<Sk className="h-2.5 w-14" />
</div>
</div>
<Sk className="h-4 w-8" />
</div>
))}
</div>
);
}
@@ -0,0 +1,38 @@
import { Activity } from 'lucide-react';
import { PageFrame, SkeletonBlock, SurfaceCard } from '../../../components/ui/surface';
export default function SchedulingSkeleton() {
return (
<PageFrame
title="智能调度工作台"
subtitle="自动识别高里程可释放车辆与低里程待救援车辆,形成可登记、可追踪的运营干预建议。"
icon={Activity}
eyebrow="SCHEDULING OPS"
meta="建议生成中 · 正在计算候选车辆"
>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => <SkeletonBlock key={i} className="h-28" />)}
</div>
<SurfaceCard>
<div className="space-y-3 p-4">
<SkeletonBlock className="h-5 w-40" />
<div className="flex gap-2">
{[0, 1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-24 rounded-full" />)}
</div>
<div className="divide-y divide-slate-50">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-3">
<SkeletonBlock className="h-10 w-1 rounded-full" />
<div className="flex-1 space-y-2">
<SkeletonBlock className="h-3.5 w-48" />
<SkeletonBlock className="h-2.5 w-72 max-w-full" />
</div>
<SkeletonBlock className="h-6 w-16" />
</div>
))}
</div>
</div>
</SurfaceCard>
</PageFrame>
);
}
@@ -0,0 +1,35 @@
import { Activity, AlertTriangle, CheckCircle2, SendHorizonal } from 'lucide-react';
import { MetricTile } from '../../../components/ui/surface';
import type { SchedulingSummary } from '../types';
import type { TypeFilter } from './model';
interface SummaryCardsProps {
summary: SchedulingSummary | undefined;
typeFilter: TypeFilter;
onTypeFilterChange: (filter: TypeFilter) => void;
onShowRecentHistory: () => void;
}
export default function SummaryCards({
summary,
typeFilter,
onTypeFilterChange,
onShowRecentHistory,
}: SummaryCardsProps) {
return (
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<button type="button" onClick={() => onTypeFilterChange(typeFilter === 'qualified' ? 'all' : 'qualified')} className={typeFilter === 'qualified' ? 'rounded-2xl ring-2 ring-orange-400 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
<MetricTile icon={CheckCircle2} label="已完成考核目标" value={summary?.qualifiedCount ?? 0} unit="台" helper="换下,腾位给待达标车" tone="amber" />
</button>
<button type="button" onClick={() => onTypeFilterChange(typeFilter === 'hopeless' ? 'all' : 'hopeless')} className={typeFilter === 'hopeless' ? 'rounded-2xl ring-2 ring-blue-500 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
<MetricTile icon={AlertTriangle} label="预估无法达标" value={summary?.hopelessCount ?? 0} unit="台" helper="换走,换上快达标的车" tone="blue" />
</button>
<button type="button" onClick={() => onTypeFilterChange('all')} className={typeFilter === 'all' ? 'rounded-2xl ring-2 ring-slate-700 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
<MetricTile icon={Activity} label="替换建议" value={summary?.suggestionCount ?? 0} unit="条" helper={`执行后预计 +${summary?.estimatedGain ?? 0} 台达标`} tone="slate" />
</button>
<button type="button" onClick={onShowRecentHistory} className="rounded-2xl">
<MetricTile icon={SendHorizonal} label="近期已干预" value={summary?.recentInterventionCount ?? 0} unit="条" helper="最近 7 天 · 点击查看" tone="emerald" />
</button>
</div>
);
}
@@ -0,0 +1,175 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type {
CandidateVehicle,
SchedulingResponse,
SchedulingSuggestion,
SchedulingVehicleInfo,
} from '../types';
import {
EMPTY_FILTERS,
buildBatchItems,
buildFilterOptions,
countActiveFilters,
filterSuggestions,
hasResettableFilters,
pickBestCandidate,
shortTargetName,
} from './model';
function vehicle(overrides: Partial<SchedulingVehicleInfo> = {}): SchedulingVehicleInfo {
return {
plateNumber: '粤A00001',
targetId: 1,
targetName: '40辆4.5T普货',
vehicleType: '4.5T普货',
totalMileage: 1000,
currentYearMileage: 500,
completionRate: 50,
yearTarget: 1000,
region: '广州',
province: '广东',
customer: '客户甲',
department: '业务一部',
manager: '负责人甲',
customerAvgDaily: 100,
customerAvgDaily7d: 100,
predictedYearEnd: 900,
daysLeft: 10,
...overrides,
};
}
function candidate(overrides: Partial<CandidateVehicle> = {}): CandidateVehicle {
return {
plateNumber: '粤A10001',
targetId: 2,
targetName: '190辆4.5T冷链',
vehicleType: '4.5T冷链',
totalMileage: 2000,
completionRate: 60,
yearTarget: 3000,
daysLeft: 20,
region: '广州',
province: '广东',
mileageGap: 1000,
predictedAfterSwap: 3200,
canQualifyAfterSwap: false,
isSameRegion: true,
notificationId: null,
notificationStatus: null,
...overrides,
};
}
function suggestion(
id: string,
overrides: Partial<SchedulingSuggestion> = {},
): SchedulingSuggestion {
return {
id,
priority: 'high',
type: 'replace_qualified',
currentVehicle: vehicle(),
candidates: [candidate()],
reason: { lines: [], conclusion: '保持现有结论' },
...overrides,
};
}
function response(suggestions: SchedulingSuggestion[]): SchedulingResponse {
return {
summary: {
qualifiedCount: 0,
hopelessCount: 0,
suggestionCount: suggestions.length,
estimatedGain: 0,
recentInterventionCount: 0,
},
suggestions,
targets: [],
};
}
test('批次简称保持现有车型替换与无法识别时的原文', () => {
assert.equal(shortTargetName('40辆4.5T普货'), '40台普货');
assert.equal(shortTargetName('190台4.5T冷链车'), '190台冷藏车');
assert.equal(shortTargetName('136辆4.5T冷链'), '136台冷藏车');
assert.equal(shortTargetName('现代双飞翼'), '现代双飞翼');
});
test('筛选计数覆盖六项,同时保留重置按钮的既有四项判断', () => {
const departmentOnly = { ...EMPTY_FILTERS, department: '业务一部' };
assert.equal(countActiveFilters(departmentOnly), 1);
assert.equal(hasResettableFilters(departmentOnly), false);
assert.equal(hasResettableFilters({ ...departmentOnly, customer: '客户甲' }), true);
});
test('候选车优先选择可达标车辆,但已有生效干预时整条跳过', () => {
const preferred = candidate({ plateNumber: '粤A10002', canQualifyAfterSwap: true });
assert.equal(
pickBestCandidate(suggestion('one', { candidates: [candidate(), preferred] }))?.plateNumber,
'粤A10002',
);
assert.equal(
pickBestCandidate(suggestion('two', {
candidates: [preferred, candidate({ notificationStatus: 'sent' })],
})),
null,
);
assert.equal(pickBestCandidate(suggestion('three', { candidates: [] })), null);
});
test('批量条目保持选择顺序并排除不存在或无可用候选的建议', () => {
const first = suggestion('first', { candidates: [candidate({ plateNumber: '粤A10001' })] });
const second = suggestion('second', {
candidates: [candidate({ plateNumber: '粤A10002', notificationStatus: 'executed' })],
});
const third = suggestion('third', { candidates: [candidate({ plateNumber: '粤A10003' })] });
const items = buildBatchItems(response([first, second, third]), new Set(['third', 'missing', 'second', 'first']));
assert.deepEqual(items.map(item => item.suggestion.id), ['third', 'first']);
assert.deepEqual(items.map(item => item.candidate.plateNumber), ['粤A10003', '粤A10001']);
});
test('筛选选项保持去空、去重和默认字符串排序', () => {
const data = response([
suggestion('one', { currentVehicle: vehicle({ region: '浙江', vehicleType: '冷链', customer: '乙', department: '二部', manager: '乙' }) }),
suggestion('two', { currentVehicle: vehicle({ region: '广东', vehicleType: '普货', customer: '甲', department: '一部', manager: '甲' }) }),
suggestion('three', { currentVehicle: vehicle({ region: '广东', vehicleType: '', customer: null, department: null, manager: null }) }),
]);
assert.deepEqual(buildFilterOptions(data), {
regions: ['广东', '浙江'],
vehicleTypes: ['冷链', '普货'],
customers: ['乙', '甲'],
departments: ['一部', '二部'],
managers: ['乙', '甲'],
});
assert.deepEqual(buildFilterOptions(null), {
regions: [], vehicleTypes: [], customers: [], departments: [], managers: [],
});
});
test('建议列表保持类型、车牌及全部高级筛选条件的交集和原顺序', () => {
const data = response([
suggestion('keep', {
type: 'rescue_hopeless',
currentVehicle: vehicle({ plateNumber: '粤AbC123', region: '广东', vehicleType: '冷链', customer: '客户甲', department: '业务一部', manager: '负责人甲' }),
}),
suggestion('wrong-type', { type: 'replace_qualified' }),
suggestion('wrong-manager', {
type: 'rescue_hopeless',
currentVehicle: vehicle({ plateNumber: '粤ABC999', vehicleType: '冷链', manager: '负责人乙' }),
}),
]);
const filters = {
plateSearch: 'ABC',
region: '广东',
vehicleType: '冷链',
customer: '客户甲',
department: '业务一部',
manager: '负责人甲',
};
assert.deepEqual(filterSuggestions(data, 'hopeless', filters).map(item => item.id), ['keep']);
assert.strictEqual(filterSuggestions(data, 'all', EMPTY_FILTERS), data.suggestions);
assert.deepEqual(filterSuggestions(null, 'all', EMPTY_FILTERS), []);
});
@@ -0,0 +1,171 @@
import type {
CandidateVehicle,
SchedulingResponse,
SchedulingSuggestion,
} from '../types';
export type TypeFilter = 'all' | 'qualified' | 'hopeless';
export interface AdvancedFilters {
plateSearch: string;
region: string;
vehicleType: string;
customer: string;
department: string;
manager: string;
}
export interface FilterOptions {
regions: string[];
vehicleTypes: string[];
customers: string[];
departments: string[];
managers: string[];
}
export interface BatchItem {
suggestion: SchedulingSuggestion;
candidate: CandidateVehicle;
}
export const EMPTY_FILTERS: AdvancedFilters = {
plateSearch: '',
region: '',
vehicleType: '',
customer: '',
department: '',
manager: '',
};
const EMPTY_FILTER_OPTIONS: FilterOptions = {
regions: [],
vehicleTypes: [],
customers: [],
departments: [],
managers: [],
};
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冷链', '冷藏车');
return `${count}${desc}`;
}
export function hasResettableFilters(filters: AdvancedFilters): boolean {
// Preserve the current reset-button condition. Department and manager alone
// do not make the reset action visible in the existing interface.
return filters.plateSearch !== ''
|| filters.region !== ''
|| filters.vehicleType !== ''
|| filters.customer !== '';
}
export function countActiveFilters(filters: AdvancedFilters): number {
return [
filters.plateSearch,
filters.region,
filters.vehicleType,
filters.customer,
filters.department,
filters.manager,
].filter(Boolean).length;
}
export function pickBestCandidate(suggestion: SchedulingSuggestion): CandidateVehicle | null {
// At most one active intervention is allowed per suggestion. If any
// candidate is already intervened, the whole suggestion is skipped.
const hasActive = suggestion.candidates.some(
candidate => candidate.notificationStatus === 'sent'
|| candidate.notificationStatus === 'executed',
);
if (hasActive) return null;
return suggestion.candidates.find(candidate => candidate.canQualifyAfterSwap)
?? suggestion.candidates[0]
?? null;
}
export function buildBatchItems(
data: SchedulingResponse | null,
selectedIds: ReadonlySet<string>,
): BatchItem[] {
if (!data) return [];
return [...selectedIds]
.map(id => data.suggestions.find(suggestion => suggestion.id === id))
.filter((suggestion): suggestion is SchedulingSuggestion => !!suggestion)
.map(suggestion => {
const candidate = pickBestCandidate(suggestion);
if (!candidate) return null;
return { suggestion, candidate };
})
.filter((item): item is BatchItem => !!item);
}
export function buildFilterOptions(data: SchedulingResponse | null): FilterOptions {
if (!data) return EMPTY_FILTER_OPTIONS;
const regions = new Set<string>();
const vehicleTypes = new Set<string>();
const customers = new Set<string>();
const departments = new Set<string>();
const managers = new Set<string>();
for (const suggestion of data.suggestions) {
const vehicle = suggestion.currentVehicle;
if (vehicle.region) regions.add(vehicle.region);
if (vehicle.vehicleType) vehicleTypes.add(vehicle.vehicleType);
if (vehicle.customer) customers.add(vehicle.customer);
if (vehicle.department) departments.add(vehicle.department);
if (vehicle.manager) managers.add(vehicle.manager);
}
return {
regions: [...regions].sort(),
vehicleTypes: [...vehicleTypes].sort(),
customers: [...customers].sort(),
departments: [...departments].sort(),
managers: [...managers].sort(),
};
}
export function filterSuggestions(
data: SchedulingResponse | null,
typeFilter: TypeFilter,
filters: AdvancedFilters,
): SchedulingSuggestion[] {
if (!data) return [];
let list = data.suggestions;
if (typeFilter === 'qualified') {
list = list.filter(suggestion => suggestion.type === 'replace_qualified');
}
if (typeFilter === 'hopeless') {
list = list.filter(suggestion => suggestion.type === 'rescue_hopeless');
}
if (filters.plateSearch) {
const query = filters.plateSearch.toLowerCase();
list = list.filter(suggestion => (
suggestion.currentVehicle.plateNumber.toLowerCase().includes(query)
));
}
if (filters.region) {
list = list.filter(suggestion => suggestion.currentVehicle.region === filters.region);
}
if (filters.vehicleType) {
list = list.filter(suggestion => suggestion.currentVehicle.vehicleType === filters.vehicleType);
}
if (filters.customer) {
list = list.filter(suggestion => suggestion.currentVehicle.customer === filters.customer);
}
if (filters.department) {
list = list.filter(suggestion => suggestion.currentVehicle.department === filters.department);
}
if (filters.manager) {
list = list.filter(suggestion => suggestion.currentVehicle.manager === filters.manager);
}
return list;
}