feat(scheduling): add batch filter and sort controls for candidate list
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Batch dropdown filter (全部批次 / per target name) - Sort by 替换后预计 (asc/desc toggle) - Sort by 当前里程 (asc/desc toggle) - Active sort button highlighted in blue - Display count shows filtered/total (e.g. "3/12 辆") Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
X, MapPin, AlertTriangle, CheckCircle, ArrowDown, ArrowUp, ArrowRight,
|
||||
X, MapPin, AlertTriangle, CheckCircle, ArrowDown, ArrowUp, ArrowRight, ArrowUpDown,
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import type { SchedulingSuggestion, CandidateVehicle } from './types';
|
||||
import Blur from '../../components/Blur';
|
||||
import SwapPreview from './SwapPreview';
|
||||
|
||||
type SortKey = 'predicted' | 'current';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
interface Props {
|
||||
suggestion: SchedulingSuggestion;
|
||||
onClose: () => void;
|
||||
@@ -25,10 +28,36 @@ function fmtRate(rate: number): string {
|
||||
export default function SuggestionDetail({ suggestion: s, onClose, onNotifySuccess }: Props) {
|
||||
const [previewCandidate, setPreviewCandidate] = useState<CandidateVehicle | null>(null);
|
||||
const [sentPlates, setSentPlates] = useState<Set<string>>(new Set());
|
||||
const [batchFilter, setBatchFilter] = useState<string>('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('predicted');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
|
||||
const v = s.currentVehicle;
|
||||
const isRescue = s.type === 'rescue_hopeless';
|
||||
|
||||
// Batch options from candidates
|
||||
const batchOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const c of s.candidates) if (c.targetName) set.add(c.targetName);
|
||||
return [...set].sort();
|
||||
}, [s.candidates]);
|
||||
|
||||
// Filtered + sorted candidates
|
||||
const displayCandidates = useMemo(() => {
|
||||
let list = s.candidates;
|
||||
if (batchFilter) list = list.filter(c => c.targetName === batchFilter);
|
||||
return [...list].sort((a, b) => {
|
||||
const va = sortKey === 'predicted' ? a.predictedAfterSwap : a.totalMileage;
|
||||
const vb = sortKey === 'predicted' ? b.predictedAfterSwap : b.totalMileage;
|
||||
return sortDir === 'desc' ? vb - va : va - vb;
|
||||
});
|
||||
}, [s.candidates, batchFilter, sortKey, sortDir]);
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) { setSortDir(d => d === 'desc' ? 'asc' : 'desc'); }
|
||||
else { setSortKey(key); setSortDir('desc'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-end sm:items-center justify-center" onClick={onClose}>
|
||||
<motion.div
|
||||
@@ -116,15 +145,48 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
||||
|
||||
{/* Candidates */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-700">当前区域所有可替换在库车辆</span>
|
||||
<span className="text-[10px] text-slate-400">{s.candidates.length} 辆</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-bold text-slate-700">当前区域可替换在库车辆</span>
|
||||
<span className="text-[10px] text-slate-400">{displayCandidates.length}/{s.candidates.length} 辆</span>
|
||||
</div>
|
||||
|
||||
{/* Filter + Sort controls */}
|
||||
<div className="flex items-center gap-2 mb-2.5 flex-wrap">
|
||||
{/* Batch filter */}
|
||||
<select
|
||||
value={batchFilter}
|
||||
onChange={e => setBatchFilter(e.target.value)}
|
||||
className="text-[10px] px-2 py-1 rounded-lg border border-slate-200 bg-white text-slate-600 cursor-pointer outline-none"
|
||||
>
|
||||
<option value="">全部批次</option>
|
||||
{batchOptions.map(b => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Sort buttons */}
|
||||
<button
|
||||
onClick={() => toggleSort('predicted')}
|
||||
className={`text-[10px] px-2 py-1 rounded-lg border flex items-center gap-1 cursor-pointer transition-colors ${
|
||||
sortKey === 'predicted' ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
替换后预计
|
||||
{sortKey === 'predicted' && (sortDir === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />)}
|
||||
{sortKey !== 'predicted' && <ArrowUpDown size={10} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleSort('current')}
|
||||
className={`text-[10px] px-2 py-1 rounded-lg border flex items-center gap-1 cursor-pointer transition-colors ${
|
||||
sortKey === 'current' ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
当前里程
|
||||
{sortKey === 'current' && (sortDir === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />)}
|
||||
{sortKey !== 'current' && <ArrowUpDown size={10} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{s.candidates.map(c => {
|
||||
{displayCandidates.map(c => {
|
||||
const sent = sentPlates.has(c.plateNumber);
|
||||
return (
|
||||
<div key={c.plateNumber} className="rounded-xl border border-slate-200 overflow-hidden bg-white">
|
||||
|
||||
Reference in New Issue
Block a user