Files
ln-bi/src/modules/scheduling/SuggestionDetail.tsx
kkfluous 3ef0d4edfa feat(scheduling): persist notifications, batch notify flow, dedup protection
- Add tab_scheduling_notifications table with bootstrap via ensureSchedulingTables()
- Notify endpoint rewritten: dedup by (suggestion_id, candidate_plate), history list, PATCH /:id for execute/cancel lifecycle
- Batch notify endpoint returns success/skipped/failed counts
- Suggestions response now carries notificationId + notificationStatus per candidate (joined from active-notification map)
- UI: select mode with checkboxes, floating action bar, confirmation modal listing each swap; already-notified items are dimmed and skipped
- Detail view badges show sent/executed state, preventing duplicate notify

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 23:43:21 +08:00

329 lines
15 KiB
TypeScript

import { useState, useMemo } from 'react';
import {
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;
onNotifySuccess: () => void;
}
function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(1) + '万';
return Math.round(value).toLocaleString();
}
function fmtRate(rate: number): string {
return (rate * 100).toFixed(1) + '%';
}
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<Set<string>>(new Set());
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, grouped by region
const { sameRegion, crossRegion } = useMemo(() => {
let list = s.candidates;
if (batchFilter.size > 0) list = list.filter(c => c.targetName != null && batchFilter.has(c.targetName));
const sorted = [...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;
});
return {
sameRegion: sorted.filter(c => c.isSameRegion),
crossRegion: sorted.filter(c => !c.isSameRegion),
};
}, [s.candidates, batchFilter, sortKey, sortDir]);
const displayCount = sameRegion.length + crossRegion.length;
const toggleSort = (key: SortKey) => {
if (sortKey === key) { setSortDir(d => d === 'desc' ? 'asc' : 'desc'); }
else { setSortKey(key); setSortDir('desc'); }
};
const renderCandidate = (c: CandidateVehicle) => {
const sent =
sentPlates.has(c.plateNumber) ||
c.notificationStatus === 'sent' ||
c.notificationStatus === 'executed';
return (
<div key={c.plateNumber} className="rounded-xl border border-slate-200 overflow-hidden bg-white">
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></span>
<span className={`text-[9px] px-1.5 py-0.5 rounded flex items-center gap-0.5 ${c.isSameRegion ? 'bg-slate-100 text-slate-500' : 'bg-amber-50 text-amber-600'}`}>
<MapPin size={9} />{c.region}{!c.isSameRegion && ' · 跨区'}
</span>
<span className="text-[9px] text-slate-400">{c.vehicleType}</span>
<span className="text-[9px] text-slate-300">{c.targetName || '库存'}</span>
<span className="text-[9px] text-slate-400">{c.daysLeft}</span>
</div>
{c.canQualifyAfterSwap ? (
<span className="text-[9px] font-bold text-emerald-600 flex items-center gap-0.5 bg-emerald-50 px-1.5 py-0.5 rounded flex-shrink-0">
<CheckCircle size={10} />
</span>
) : (
<span className="text-[9px] font-bold text-amber-500 flex items-center gap-0.5 bg-amber-50 px-1.5 py-0.5 rounded flex-shrink-0">
<AlertTriangle size={10} />
</span>
)}
</div>
<div className="px-3 pb-2">
<div className="flex text-[10px] bg-slate-50 rounded-lg overflow-hidden divide-x divide-slate-200">
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div>
<div className="font-bold text-slate-700">{fmtKm(c.totalMileage)}</div>
</div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div>
<div className={`font-bold ${c.canQualifyAfterSwap ? 'text-emerald-600' : 'text-amber-600'}`}>{fmtKm(c.predictedAfterSwap)}</div>
</div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-blue-400"></div>
<div className="font-bold text-blue-700">{c.yearTarget ? fmtKm(c.yearTarget) : '-'}</div>
</div>
</div>
</div>
<div className="px-3 pb-2.5">
<button
onClick={() => setPreviewCandidate(c)}
disabled={sent}
className={`w-full flex items-center justify-center gap-1.5 text-[11px] font-bold py-2 rounded-lg transition-all cursor-pointer ${
sent
? 'bg-emerald-50 text-emerald-600'
: 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-sm'
}`}
>
{sent ? <><CheckCircle size={12} /> </> : <> <ArrowRight size={12} /></>}
</button>
</div>
</div>
);
};
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
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-lg overflow-hidden flex flex-col max-h-[92vh] sm:max-h-[85vh] sm:mx-4"
>
{/* Header — unified dark slate */}
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2">
{isRescue
? <ArrowDown size={14} className="text-blue-300" />
: <ArrowUp size={14} className="text-amber-300" />
}
<span className="text-white font-bold text-sm">
{isRescue ? '里程低·换走此车' : '里程高·换下此车'}
</span>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors p-1 cursor-pointer">
<X size={18} />
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1 no-scrollbar">
{/* Current Vehicle — same format as candidate cards */}
<div className="px-4 py-3 border-b border-slate-100">
<div className="rounded-xl border border-slate-200 overflow-hidden bg-white">
{/* Header — same style as candidate header */}
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{v.plateNumber}</Blur></span>
<span className="text-[9px] text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded flex items-center gap-0.5"><MapPin size={9} />{v.region}</span>
<span className="text-[9px] text-slate-400">{v.vehicleType}</span>
<span className="text-[9px] text-slate-300">{v.targetName}</span>
<span className="text-[9px] text-slate-400">{v.daysLeft}</span>
</div>
<span className={`text-sm font-black tabular-nums ${v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}`}>
{fmtRate(v.completionRate)}
</span>
</div>
{/* Customer + dept/manager info */}
<div className="px-3 pb-1.5 flex items-center gap-2 text-[10px] text-slate-500 flex-wrap">
{v.department && <span><b className="text-slate-700">{v.department}</b></span>}
{v.manager && <span><b className="text-slate-700">{v.manager}</b></span>}
{(v.department || v.manager) && <span className="text-slate-200">|</span>}
<span> <b className="text-slate-700"><Blur>{v.customer || '-'}</Blur></b></span>
<span> <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b> km</span>
</div>
{/* Metrics */}
<div className="px-3 pb-2">
<div className="flex text-[10px] bg-slate-50 rounded-lg overflow-hidden divide-x divide-slate-200">
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div>
<div className="font-bold text-slate-700">{fmtKm(v.currentYearMileage)}</div>
</div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div>
<div className={`font-bold ${v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}`}>{fmtKm(v.currentYearMileage + v.customerAvgDaily * v.daysLeft)}</div>
</div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-blue-400"></div>
<div className="font-bold text-blue-700">{fmtKm(v.yearTarget)}</div>
</div>
</div>
</div>
</div>
</div>
{/* Reason — structured lines */}
<div className="px-4 py-2.5 border-b border-slate-100 bg-slate-50/60">
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
{s.reason.lines.map((line, i) => (
<div key={i} className="flex items-center justify-between text-[11px]">
<span className="text-slate-500">{line.label}</span>
<span className="text-slate-700 font-medium">{line.value}</span>
</div>
))}
</div>
<div className="mt-2 pt-2 border-t border-slate-200">
<span className="text-xs font-bold text-rose-600">{s.reason.conclusion}</span>
</div>
</div>
{/* Candidates */}
<div className="px-4 py-3">
<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">{displayCount}/{s.candidates.length} </span>
</div>
{/* Filter + Sort controls */}
<div className="flex items-center gap-2 mb-2.5 flex-wrap">
{/* Batch multi-select pills */}
<div className="flex items-center gap-1.5 flex-wrap">
<button
onClick={() => setBatchFilter(new Set())}
className={`text-[10px] px-2 py-1 rounded-lg border cursor-pointer transition-colors ${
batchFilter.size === 0 ? 'border-blue-300 bg-blue-50 text-blue-700 font-bold' : 'border-slate-200 text-slate-500'
}`}
>
</button>
{batchOptions.map(b => {
const active = batchFilter.has(b);
return (
<button
key={b}
onClick={() => setBatchFilter(prev => {
const next = new Set(prev);
if (active) next.delete(b); else next.add(b);
return next;
})}
className={`text-[10px] px-2 py-1 rounded-lg border cursor-pointer transition-colors ${
active ? 'border-blue-300 bg-blue-50 text-blue-700 font-bold' : 'border-slate-200 text-slate-500'
}`}
>
{b}
</button>
);
})}
</div>
{/* 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>
{sameRegion.length > 0 && (
<div className="space-y-2">
{sameRegion.map(c => renderCandidate(c))}
</div>
)}
{crossRegion.length > 0 && (
<>
<div className="flex items-center gap-2 my-3">
<div className="flex-1 h-px bg-slate-200" />
<span className="text-[10px] text-slate-400 font-medium"> · {crossRegion.length} </span>
<div className="flex-1 h-px bg-slate-200" />
</div>
<div className="space-y-2">
{crossRegion.map(c => renderCandidate(c))}
</div>
</>
)}
{displayCount === 0 && (
<div className="py-8 text-center text-xs text-slate-400"></div>
)}
</div>
</div>
{/* Footer */}
<div className="border-t border-slate-100 px-4 py-2.5 flex-shrink-0">
<button
onClick={onClose}
className="w-full py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors cursor-pointer"
>
</button>
</div>
</motion.div>
{/* Full-screen swap preview */}
{previewCandidate && (
<SwapPreview
suggestion={s}
candidate={previewCandidate}
onClose={() => setPreviewCandidate(null)}
onSuccess={() => {
setSentPlates(prev => new Set(prev).add(previewCandidate.plateNumber));
setPreviewCandidate(null);
onNotifySuccess();
}}
/>
)}
</div>
);
}