feat(scheduling): history view, execute/cancel lifecycle, CSV export, 7d trend

- Add 调度记录 modal: lists notifications by status, supports 标记已执行 (with
  after-mileage + notes) and 取消 for open records
- Add CSV export of filtered suggestions (UTF-8 BOM for Excel); top candidate
  per row picked by same-region > can-qualify preference
- Compute customer 7-day average alongside 30-day baseline in a single query;
  show trend indicator (up/down/flat) next to 客户日均 in list and detail card

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-16 23:47:31 +08:00
parent 3ef0d4edfa
commit 1d9f4cb43d
9 changed files with 459 additions and 18 deletions

View File

@@ -0,0 +1,272 @@
import { useCallback, useEffect, useState } from 'react';
import { X, RotateCcw, Clock, CheckCircle2, XCircle, Send, Loader2 } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchNotifications, updateNotification } from './api';
import type { NotificationRecord, NotificationStatus } from './types';
import Blur from '../../components/Blur';
interface Props {
onClose: () => void;
onChange?: () => void;
}
type StatusTab = 'all' | NotificationStatus;
const STATUS_TABS: { key: StatusTab; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'sent', label: '待执行' },
{ key: 'executed', label: '已执行' },
{ key: 'cancelled', label: '已取消' },
];
function statusBadge(status: NotificationStatus) {
if (status === 'sent') return { text: '待执行', icon: <Send size={9} />, cls: 'text-amber-700 bg-amber-50' };
if (status === 'executed') return { text: '已执行', icon: <CheckCircle2 size={9} />, cls: 'text-emerald-700 bg-emerald-50' };
return { text: '已取消', icon: <XCircle size={9} />, cls: 'text-slate-500 bg-slate-100' };
}
function fmtDateTime(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${y}-${m}-${day} ${hh}:${mm}`;
}
export default function NotificationHistory({ onClose, onChange }: Props) {
const [records, setRecords] = useState<NotificationRecord[]>([]);
const [loading, setLoading] = useState(false);
const [tab, setTab] = useState<StatusTab>('all');
const [mutatingId, setMutatingId] = useState<number | null>(null);
const [executeTarget, setExecuteTarget] = useState<NotificationRecord | null>(null);
const [afterMileageInput, setAfterMileageInput] = useState('');
const [notesInput, setNotesInput] = useState('');
const load = useCallback(async () => {
setLoading(true);
try {
const resp = await fetchNotifications(tab === 'all' ? undefined : tab);
setRecords(resp.records);
} finally {
setLoading(false);
}
}, [tab]);
useEffect(() => { load(); }, [load]);
const handleExecuteClick = (rec: NotificationRecord) => {
setExecuteTarget(rec);
setAfterMileageInput('');
setNotesInput('');
};
const handleExecuteConfirm = async () => {
if (!executeTarget) return;
setMutatingId(executeTarget.id);
try {
const body: { status: NotificationStatus; notes?: string; afterMileage?: number } = { status: 'executed' };
if (notesInput.trim()) body.notes = notesInput.trim();
const parsed = Number(afterMileageInput);
if (Number.isFinite(parsed) && parsed > 0) body.afterMileage = parsed;
await updateNotification(executeTarget.id, body);
setExecuteTarget(null);
await load();
onChange?.();
} finally {
setMutatingId(null);
}
};
const handleCancel = async (rec: NotificationRecord) => {
if (!confirm(`确定取消 ${rec.currentPlate}${rec.candidatePlate} 的替换通知?`)) return;
setMutatingId(rec.id);
try {
await updateNotification(rec.id, { status: 'cancelled' });
await load();
onChange?.();
} finally {
setMutatingId(null);
}
};
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-xl overflow-hidden flex flex-col max-h-[85vh] sm:mx-4"
>
{/* Header */}
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2">
<Clock size={16} className="text-white" />
<span className="text-white font-bold text-sm"></span>
</div>
<div className="flex items-center gap-1">
<button onClick={load} disabled={loading} className="text-slate-300 hover:text-white p-1 cursor-pointer">
<RotateCcw size={14} className={loading ? 'animate-spin' : ''} />
</button>
<button onClick={onClose} className="text-slate-300 hover:text-white p-1 cursor-pointer">
<X size={18} />
</button>
</div>
</div>
{/* Status tabs */}
<div className="border-b border-slate-100 px-4 py-2 flex gap-1.5 flex-shrink-0">
{STATUS_TABS.map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`text-[11px] px-3 py-1 rounded-full font-medium cursor-pointer transition-colors ${
tab === t.key ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
{t.label}
</button>
))}
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto">
{loading && records.length === 0 ? (
<div className="py-16 text-center text-slate-400 text-xs flex items-center justify-center gap-2">
<Loader2 size={14} className="animate-spin" />
</div>
) : records.length === 0 ? (
<div className="py-16 text-center text-slate-400">
<Clock className="w-8 h-8 text-slate-200 mx-auto mb-2" />
<p className="text-sm"></p>
</div>
) : (
<div className="divide-y divide-slate-50">
{records.map(rec => {
const badge = statusBadge(rec.status);
const busy = mutatingId === rec.id;
return (
<div key={rec.id} className="px-4 py-3">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-1.5 text-xs min-w-0">
<span className="font-mono font-bold text-slate-900"><Blur>{rec.currentPlate}</Blur></span>
<span className="text-slate-400"></span>
<span className="font-mono font-bold text-blue-700"><Blur>{rec.candidatePlate}</Blur></span>
</div>
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded flex items-center gap-0.5 flex-shrink-0 ${badge.cls}`}>
{badge.icon} {badge.text}
</span>
</div>
<div className="flex items-center gap-3 text-[10px] text-slate-400">
{rec.operatorName && <span> {rec.operatorName}</span>}
<span>{fmtDateTime(rec.createdAt)}</span>
{rec.status === 'executed' && rec.executedAt && (
<span className="text-emerald-500"> {fmtDateTime(rec.executedAt)}</span>
)}
</div>
{rec.notes && (
<div className="mt-1 text-[10px] text-slate-500 bg-slate-50 rounded px-2 py-1">{rec.notes}</div>
)}
{rec.status === 'sent' && (
<div className="mt-2 flex items-center gap-2">
<button
onClick={() => handleExecuteClick(rec)}
disabled={busy}
className="text-[10px] font-bold text-white bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 px-2.5 py-1 rounded cursor-pointer transition-colors flex items-center gap-1"
>
<CheckCircle2 size={10} />
</button>
<button
onClick={() => handleCancel(rec)}
disabled={busy}
className="text-[10px] font-medium text-slate-500 hover:text-rose-600 disabled:opacity-50 px-2 py-1 rounded cursor-pointer transition-colors"
>
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</motion.div>
{/* Execute confirmation modal */}
<AnimatePresence>
{executeTarget && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[80] flex items-end sm:items-center justify-center"
onClick={() => mutatingId === null && setExecuteTarget(null)}
>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 40, opacity: 0 }}
onClick={e => e.stopPropagation()}
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-sm overflow-hidden flex flex-col sm:mx-4"
>
<div className="bg-emerald-600 px-4 py-3 flex items-center justify-between">
<span className="text-white font-bold text-sm"></span>
<button
onClick={() => mutatingId === null && setExecuteTarget(null)}
disabled={mutatingId !== null}
className="text-emerald-100 hover:text-white p-1 cursor-pointer disabled:opacity-50"
>
<X size={16} />
</button>
</div>
<div className="px-4 py-4 space-y-3">
<div className="text-xs text-slate-500">
<span className="font-mono font-bold text-slate-900"><Blur>{executeTarget.currentPlate}</Blur></span>
<span className="mx-1.5"></span>
<span className="font-mono font-bold text-blue-700"><Blur>{executeTarget.candidatePlate}</Blur></span>
</div>
<div>
<label className="text-[10px] text-slate-400 uppercase font-bold block mb-1"> (km, )</label>
<input
type="number"
inputMode="numeric"
value={afterMileageInput}
onChange={e => setAfterMileageInput(e.target.value)}
placeholder="例如 45230"
className="w-full px-3 py-2 text-xs bg-slate-50 rounded-lg border border-slate-200 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-400 transition-all"
/>
</div>
<div>
<label className="text-[10px] text-slate-400 uppercase font-bold block mb-1"> ()</label>
<textarea
value={notesInput}
onChange={e => setNotesInput(e.target.value)}
rows={2}
placeholder="例如:司机已到位,交接完成"
className="w-full px-3 py-2 text-xs bg-slate-50 rounded-lg border border-slate-200 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-400 transition-all resize-none"
/>
</div>
</div>
<div className="border-t border-slate-100 px-4 py-3 flex gap-2">
<button
onClick={() => setExecuteTarget(null)}
disabled={mutatingId !== null}
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={handleExecuteConfirm}
disabled={mutatingId !== null}
className="flex-1 py-2 text-xs font-bold text-white bg-emerald-600 hover:bg-emerald-500 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
>
{mutatingId !== null ? '保存中...' : '确认'}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}