- 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>
329 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|