feat(scheduling): enrich history records with customer/dept/manager + drill-in to swap plan

Each row in 调度记录 now shows 业务部门(简)/业务负责人/客户 beneath
the plate line, and is clickable to open the reusable SwapPreview
showing the full replacement plan (current mileage, 考核目标, 替换后预测).
Drill-in is only enabled when the suggestion is still in the active
scheduling view; the user can still 取消干预 from the preview.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-17 09:30:07 +08:00
parent ba1e0e9f16
commit 9d1e8c4d30
2 changed files with 57 additions and 9 deletions

View File

@@ -1,19 +1,26 @@
import { useCallback, useEffect, useState } from 'react';
import { X, RotateCcw, Clock, CheckCircle2, XCircle, Send, Loader2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { X, RotateCcw, Clock, CheckCircle2, XCircle, Send, Loader2, ChevronRight } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchNotifications, updateNotification } from './api';
import type { NotificationRecord, NotificationStatus } from './types';
import type { NotificationRecord, NotificationStatus, SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur';
import SwapPreview from './SwapPreview';
interface Props {
onClose: () => void;
onChange?: () => void;
/** When true, pre-filter to the last 7 days (excluding cancelled). */
recentOnly?: boolean;
/** Current suggestions used to enrich records with customer/dept/manager and enable drill-down. */
suggestions?: SchedulingSuggestion[];
}
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
function shortDept(dept: string | null | undefined): string {
return (dept || '').replace('业务', '');
}
type StatusTab = 'all' | NotificationStatus;
const STATUS_TABS: { key: StatusTab; label: string }[] = [
@@ -40,7 +47,7 @@ function fmtDateTime(iso: string): string {
return `${y}-${m}-${day} ${hh}:${mm}`;
}
export default function NotificationHistory({ onClose, onChange, recentOnly = false }: Props) {
export default function NotificationHistory({ onClose, onChange, recentOnly = false, suggestions }: Props) {
const [records, setRecords] = useState<NotificationRecord[]>([]);
const [loading, setLoading] = useState(false);
const [tab, setTab] = useState<StatusTab>('all');
@@ -49,6 +56,13 @@ export default function NotificationHistory({ onClose, onChange, recentOnly = fa
const [executeTarget, setExecuteTarget] = useState<NotificationRecord | null>(null);
const [afterMileageInput, setAfterMileageInput] = useState('');
const [notesInput, setNotesInput] = useState('');
const [drillTarget, setDrillTarget] = useState<{ suggestion: SchedulingSuggestion; candidate: CandidateVehicle } | null>(null);
const suggestionById = useMemo(() => {
const map = new Map<string, SchedulingSuggestion>();
for (const s of suggestions ?? []) map.set(s.id, s);
return map;
}, [suggestions]);
const visibleRecords = recent7d
? records.filter(r => {
@@ -170,18 +184,41 @@ export default function NotificationHistory({ onClose, onChange, recentOnly = fa
{visibleRecords.map(rec => {
const badge = statusBadge(rec.status);
const busy = mutatingId === rec.id;
const suggestion = suggestionById.get(rec.suggestionId);
const candidate = suggestion?.candidates.find(c => c.plateNumber === rec.candidatePlate) ?? null;
const canDrill = !!suggestion && !!candidate;
const v = suggestion?.currentVehicle;
const handleRowClick = () => {
if (canDrill && suggestion && candidate) setDrillTarget({ suggestion, candidate });
};
return (
<div key={rec.id} className="px-4 py-3">
<div
key={rec.id}
onClick={handleRowClick}
className={`px-4 py-3 transition-colors ${canDrill ? 'cursor-pointer hover:bg-slate-50/60 active:bg-slate-100' : ''}`}
>
<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 className="flex items-center gap-1.5 flex-shrink-0">
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded flex items-center gap-0.5 ${badge.cls}`}>
{badge.icon} {badge.text}
</span>
{canDrill && <ChevronRight size={12} className="text-slate-300" />}
</div>
</div>
{v && (
<div className="flex items-center gap-1.5 text-[10px] text-slate-500 mb-0.5 truncate">
{v.department && <span className="font-medium">{shortDept(v.department)}</span>}
{v.manager && <span>{v.manager}</span>}
<span className="text-slate-400 truncate"><Blur>{v.customer || '-'}</Blur></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>
@@ -193,7 +230,7 @@ export default function NotificationHistory({ onClose, onChange, recentOnly = fa
<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">
<div className="mt-2 flex items-center gap-2" onClick={e => e.stopPropagation()}>
<button
onClick={() => handleExecuteClick(rec)}
disabled={busy}
@@ -290,6 +327,16 @@ export default function NotificationHistory({ onClose, onChange, recentOnly = fa
</div>
)}
</AnimatePresence>
{/* Drill-down: replacement plan */}
{drillTarget && (
<SwapPreview
suggestion={drillTarget.suggestion}
candidate={drillTarget.candidate}
onClose={() => setDrillTarget(null)}
onSuccess={() => { load(); onChange?.(); }}
/>
)}
</div>
);
}