diff --git a/src/modules/scheduling/SchedulingModule.tsx b/src/modules/scheduling/SchedulingModule.tsx index 7f8d302..df8ecee 100644 --- a/src/modules/scheduling/SchedulingModule.tsx +++ b/src/modules/scheduling/SchedulingModule.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; -import { RotateCcw } from 'lucide-react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { Filter, RotateCcw, X, Search, ChevronDown } from 'lucide-react'; +import { motion, AnimatePresence } from 'motion/react'; import { fetchSuggestions } from './api'; import type { SchedulingResponse, SchedulingSuggestion } from './types'; import SuggestionList from './SuggestionList'; @@ -7,6 +8,15 @@ import SuggestionDetail from './SuggestionDetail'; type TypeFilter = 'all' | 'qualified' | 'hopeless'; +interface AdvancedFilters { + plateSearch: string; + region: string; + vehicleType: string; + customer: string; +} + +const EMPTY_FILTERS: AdvancedFilters = { plateSearch: '', region: '', vehicleType: '', customer: '' }; + function shortTargetName(name: string): string { const match = name.match(/(\d+)[辆台](.+)/); if (!match) return name; @@ -18,12 +28,91 @@ function shortTargetName(name: string): string { return `${count}台${desc}`; } +function hasActiveFilters(f: AdvancedFilters): boolean { + return f.plateSearch !== '' || f.region !== '' || f.vehicleType !== '' || f.customer !== ''; +} + +/** Dropdown select with search */ +function FilterSelect({ label, options, value, onChange, placeholder }: { + label: string; + options: string[]; + value: string; + onChange: (v: string) => void; + placeholder: string; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const ref = useRef(null); + + const filtered = options.filter(o => o.toLowerCase().includes(search.toLowerCase())); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + return ( +
+ + + {open && ( +
+ {options.length > 5 && ( +
+
+ + setSearch(e.target.value)} + placeholder="搜索..." + className="w-full pl-7 pr-2 py-1.5 text-xs bg-slate-50 rounded border-none outline-none" + autoFocus + /> +
+
+ )} +
+ + {filtered.map(opt => ( + + ))} +
+
+ )} +
+ ); +} + export default function SchedulingModule() { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [selectedTargetId, setSelectedTargetId] = useState(undefined); const [selectedSuggestion, setSelectedSuggestion] = useState(null); const [typeFilter, setTypeFilter] = useState('all'); + const [showFilter, setShowFilter] = useState(false); + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [tempFilters, setTempFilters] = useState(EMPTY_FILTERS); const loadData = useCallback(async () => { setLoading(true); @@ -36,133 +125,309 @@ export default function SchedulingModule() { }, [selectedTargetId]); useEffect(() => { loadData(); }, [loadData]); - const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]); + // Compute filter options from data + const filterOptions = useMemo(() => { + if (!data) return { regions: [], vehicleTypes: [], customers: [] }; + const regions = new Set(); + const vehicleTypes = new Set(); + const customers = new Set(); + for (const s of data.suggestions) { + const v = s.currentVehicle; + if (v.region) regions.add(v.region); + if (v.vehicleType) vehicleTypes.add(v.vehicleType); + if (v.customer) customers.add(v.customer); + } + return { + regions: Array.from(regions).sort(), + vehicleTypes: Array.from(vehicleTypes).sort(), + customers: Array.from(customers).sort(), + }; + }, [data]); + const filteredSuggestions = useMemo(() => { if (!data) return []; - if (typeFilter === 'qualified') return data.suggestions.filter(s => s.type === 'replace_qualified'); - if (typeFilter === 'hopeless') return data.suggestions.filter(s => s.type === 'rescue_hopeless'); - return data.suggestions; - }, [data, typeFilter]); + let list = data.suggestions; - const cardConfigs = [ - { - key: 'qualified' as TypeFilter, - label: '已达标', - count: data?.summary.qualifiedCount ?? 0, - unit: '台', - sub: null, - // Muted teal/green - idle: 'border-slate-200 bg-white', - active: 'border-emerald-500 bg-emerald-50 ring-1 ring-emerald-500/20', - labelColor: 'text-slate-500', - labelActive: 'text-emerald-600', - numColor: 'text-slate-800', - numActive: 'text-emerald-700', - }, - { - key: 'hopeless' as TypeFilter, - label: '无望达标', - count: data?.summary.hopelessCount ?? 0, - unit: '台', - sub: null, - // Warm red - idle: 'border-slate-200 bg-white', - active: 'border-rose-500 bg-rose-50 ring-1 ring-rose-500/20', - labelColor: 'text-slate-500', - labelActive: 'text-rose-600', - numColor: 'text-slate-800', - numActive: 'text-rose-700', - }, - { - key: 'all' as TypeFilter, - label: '可干预', - count: data?.summary.suggestionCount ?? 0, - unit: '条', - sub: data ? `+${data.summary.estimatedGain} 台可达标` : null, - // Blue accent - idle: 'border-slate-200 bg-white', - active: 'border-blue-500 bg-blue-50 ring-1 ring-blue-500/20', - labelColor: 'text-slate-500', - labelActive: 'text-blue-600', - numColor: 'text-slate-800', - numActive: 'text-blue-700', - }, - ]; + // Type filter from cards + if (typeFilter === 'qualified') list = list.filter(s => s.type === 'replace_qualified'); + if (typeFilter === 'hopeless') list = list.filter(s => s.type === 'rescue_hopeless'); + + // Advanced filters + 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); + + return list; + }, [data, typeFilter, filters]); + + const summary = data?.summary; + const activeFilterCount = [filters.plateSearch, filters.region, filters.vehicleType, filters.customer].filter(Boolean).length; return (
- {/* Batch Selector */} -
+ {/* ========== Top: Summary Cards ========== */} +
+ + + + - {data?.targets.map((target) => ( - - ))}
- {loading && !data ? ( -
-
-
- ) : data ? ( - <> - {/* Summary Cards — clickable filter */} -
- {cardConfigs.map(cfg => { - const isActive = typeFilter === cfg.key; - return ( - - ); - })} + {/* ========== Bottom: List Card ========== */} +
+ + {/* Header */} +
+
+

智能调度干预清单

+
+ + +
- {/* Suggestion List */} + {/* Batch selector pills */} +
+ + {data?.targets.map(t => ( + + ))} +
+
+ + {/* Advanced Filter Panel */} + + {showFilter && ( + +
+
+ 高级筛选 + {hasActiveFilters(tempFilters) && ( + + )} +
+ + {/* Plate search */} +
+ +
+ + setTempFilters(prev => ({ ...prev, plateSearch: e.target.value }))} + placeholder="搜索车牌号..." + className="w-full pl-8 pr-3 py-2 bg-white rounded-lg text-xs border border-slate-200 outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all" + /> +
+
+ + {/* Selects in 2-column grid */} +
+ setTempFilters(prev => ({ ...prev, region: v }))} + placeholder="全部区域" + /> + setTempFilters(prev => ({ ...prev, vehicleType: v }))} + placeholder="全部类型" + /> +
+ + setTempFilters(prev => ({ ...prev, customer: v }))} + placeholder="全部客户" + /> + + {/* Apply / Cancel */} +
+ + +
+
+
+ )} +
+ + {/* Active filter tags */} + {activeFilterCount > 0 && !showFilter && ( +
+ 筛选: + {filters.plateSearch && ( + + 车牌 "{filters.plateSearch}" + setFilters(prev => ({ ...prev, plateSearch: '' }))} /> + + )} + {filters.region && ( + + {filters.region} + setFilters(prev => ({ ...prev, region: '' }))} /> + + )} + {filters.vehicleType && ( + + {filters.vehicleType} + setFilters(prev => ({ ...prev, vehicleType: '' }))} /> + + )} + {filters.customer && ( + + {filters.customer} + setFilters(prev => ({ ...prev, customer: '' }))} /> + + )} + +
+ )} + + {/* Result count when filtered */} + {(activeFilterCount > 0 || typeFilter !== 'all') && ( +
+ 共 {filteredSuggestions.length} 条结果 +
+ )} + + {/* List body */} + {loading && !data ? ( +
+
+
+ ) : ( - - ) : null} + )} +
+ {/* Detail Modal */} {selectedSuggestion && ( void; - loading?: boolean; - onRefresh?: () => void; } function fmtRate(rate: number): string { return (rate * 100).toFixed(1) + '%'; } -export default function SuggestionList({ suggestions, onSelect, loading, onRefresh }: Props) { +export default function SuggestionList({ suggestions, onSelect }: Props) { if (suggestions.length === 0) { return ( -
+

暂无调度建议

@@ -25,73 +23,54 @@ export default function SuggestionList({ suggestions, onSelect, loading, onRefre } return ( -
-
-
- 调度干预清单 - {onRefresh && ( - - )} - - {suggestions.length} - -
+ {/* Color bar */} +
-
- {suggestions.map((s, idx) => { - const isRescue = s.type === 'rescue_hopeless'; - const v = s.currentVehicle; - - return ( - onSelect(s)} - > - {/* Color bar */} -
- - {/* Info */} -
-
- - {v.plateNumber} - - - {isRescue ? '无望' : '达标'} - - {v.vehicleType} - · - {v.region} -
-
- {v.customer || '-'} - 日均 {Math.round(v.customerAvgDaily)} km - 完成 = 1 ? 'text-emerald-600' : v.completionRate >= 0.5 ? 'text-amber-600' : 'text-rose-500'}`}>{fmtRate(v.completionRate)} -
+ {/* Info */} +
+
+ + {v.plateNumber} + + + {isRescue ? '无望' : '达标'} + + {v.vehicleType} + · + {v.region}
- - {/* Right */} -
- {s.candidates.length} - - +
+ {v.customer || '-'} + 日均 {Math.round(v.customerAvgDaily)} km + 完成 = 1 ? 'text-emerald-600' : v.completionRate >= 0.5 ? 'text-amber-600' : 'text-rose-500'}`}>{fmtRate(v.completionRate)}
- - ); - })} -
+
+ + {/* Right */} +
+ {s.candidates.length} + + +
+ + ); + })}
); }