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:
@@ -1,19 +1,26 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { X, RotateCcw, Clock, CheckCircle2, XCircle, Send, Loader2 } from 'lucide-react';
|
import { X, RotateCcw, Clock, CheckCircle2, XCircle, Send, Loader2, ChevronRight } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { fetchNotifications, updateNotification } from './api';
|
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 Blur from '../../components/Blur';
|
||||||
|
import SwapPreview from './SwapPreview';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onChange?: () => void;
|
onChange?: () => void;
|
||||||
/** When true, pre-filter to the last 7 days (excluding cancelled). */
|
/** When true, pre-filter to the last 7 days (excluding cancelled). */
|
||||||
recentOnly?: boolean;
|
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;
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function shortDept(dept: string | null | undefined): string {
|
||||||
|
return (dept || '').replace('业务', '');
|
||||||
|
}
|
||||||
|
|
||||||
type StatusTab = 'all' | NotificationStatus;
|
type StatusTab = 'all' | NotificationStatus;
|
||||||
|
|
||||||
const STATUS_TABS: { key: StatusTab; label: string }[] = [
|
const STATUS_TABS: { key: StatusTab; label: string }[] = [
|
||||||
@@ -40,7 +47,7 @@ function fmtDateTime(iso: string): string {
|
|||||||
return `${y}-${m}-${day} ${hh}:${mm}`;
|
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 [records, setRecords] = useState<NotificationRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [tab, setTab] = useState<StatusTab>('all');
|
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 [executeTarget, setExecuteTarget] = useState<NotificationRecord | null>(null);
|
||||||
const [afterMileageInput, setAfterMileageInput] = useState('');
|
const [afterMileageInput, setAfterMileageInput] = useState('');
|
||||||
const [notesInput, setNotesInput] = 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
|
const visibleRecords = recent7d
|
||||||
? records.filter(r => {
|
? records.filter(r => {
|
||||||
@@ -170,18 +184,41 @@ export default function NotificationHistory({ onClose, onChange, recentOnly = fa
|
|||||||
{visibleRecords.map(rec => {
|
{visibleRecords.map(rec => {
|
||||||
const badge = statusBadge(rec.status);
|
const badge = statusBadge(rec.status);
|
||||||
const busy = mutatingId === rec.id;
|
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 (
|
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 justify-between gap-2 mb-1">
|
||||||
<div className="flex items-center gap-1.5 text-xs min-w-0">
|
<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="font-mono font-bold text-slate-900"><Blur>{rec.currentPlate}</Blur></span>
|
||||||
<span className="text-slate-400">→</span>
|
<span className="text-slate-400">→</span>
|
||||||
<span className="font-mono font-bold text-blue-700"><Blur>{rec.candidatePlate}</Blur></span>
|
<span className="font-mono font-bold text-blue-700"><Blur>{rec.candidatePlate}</Blur></span>
|
||||||
</div>
|
</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}`}>
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
{badge.icon} {badge.text}
|
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded flex items-center gap-0.5 ${badge.cls}`}>
|
||||||
</span>
|
{badge.icon} {badge.text}
|
||||||
|
</span>
|
||||||
|
{canDrill && <ChevronRight size={12} className="text-slate-300" />}
|
||||||
|
</div>
|
||||||
</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">
|
<div className="flex items-center gap-3 text-[10px] text-slate-400">
|
||||||
{rec.operatorName && <span>操作人 {rec.operatorName}</span>}
|
{rec.operatorName && <span>操作人 {rec.operatorName}</span>}
|
||||||
<span>{fmtDateTime(rec.createdAt)}</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>
|
<div className="mt-1 text-[10px] text-slate-500 bg-slate-50 rounded px-2 py-1">{rec.notes}</div>
|
||||||
)}
|
)}
|
||||||
{rec.status === 'sent' && (
|
{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
|
<button
|
||||||
onClick={() => handleExecuteClick(rec)}
|
onClick={() => handleExecuteClick(rec)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -290,6 +327,16 @@ export default function NotificationHistory({ onClose, onChange, recentOnly = fa
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Drill-down: replacement plan */}
|
||||||
|
{drillTarget && (
|
||||||
|
<SwapPreview
|
||||||
|
suggestion={drillTarget.suggestion}
|
||||||
|
candidate={drillTarget.candidate}
|
||||||
|
onClose={() => setDrillTarget(null)}
|
||||||
|
onSuccess={() => { load(); onChange?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -532,6 +532,7 @@ export default function SchedulingModule() {
|
|||||||
onClose={() => setShowHistory(false)}
|
onClose={() => setShowHistory(false)}
|
||||||
onChange={loadData}
|
onChange={loadData}
|
||||||
recentOnly={historyRecentOnly}
|
recentOnly={historyRecentOnly}
|
||||||
|
suggestions={data?.suggestions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user