refactor(scheduling): shared types, structured reason, cross-region candidates
- Extract shared types to src/shared/scheduling/types.ts (client/server both re-export)
- Convert SchedulingSuggestion.reason from string to structured { lines, conclusion }
- Remove hard region filter; algorithm keeps cross-region candidates with isSameRegion flag
- SuggestionDetail renders same-region vs cross-region sections with a divider
- Close detail modal when selected suggestion no longer exists in data
- Unify estimatedGain definition (strict canQualifyAfterSwap) between algorithm and API layers
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -161,6 +161,13 @@ export default function SchedulingModule() {
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]);
|
||||
|
||||
// Close detail modal if selected suggestion is filtered out or no longer exists
|
||||
useEffect(() => {
|
||||
if (!selectedSuggestion || !data) return;
|
||||
const stillExists = data.suggestions.some(s => s.id === selectedSuggestion.id);
|
||||
if (!stillExists) setSelectedSuggestion(null);
|
||||
}, [data, selectedSuggestion]);
|
||||
|
||||
const filterOptions = useMemo(() => {
|
||||
if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] };
|
||||
const r = new Set<string>(), t = new Set<string>(), c = new Set<string>(), d = new Set<string>(), m = new Set<string>();
|
||||
|
||||
@@ -42,22 +42,87 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
||||
return [...set].sort();
|
||||
}, [s.candidates]);
|
||||
|
||||
// Filtered + sorted candidates
|
||||
const displayCandidates = useMemo(() => {
|
||||
// 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));
|
||||
return [...list].sort((a, b) => {
|
||||
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);
|
||||
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
|
||||
@@ -130,41 +195,25 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
||||
</div>
|
||||
|
||||
{/* Reason — structured lines */}
|
||||
<div className="px-4 py-2.5 border-b border-slate-100 bg-slate-50/60 space-y-1">
|
||||
{s.reason.split('\n').map((line, i) => {
|
||||
const isConclusion = line.startsWith('!!');
|
||||
const text = isConclusion ? line.slice(2) : line;
|
||||
if (isConclusion) {
|
||||
return (
|
||||
<div key={i} className="mt-1.5 pt-1.5 border-t border-slate-200">
|
||||
<span className="text-xs font-bold text-rose-600">{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Split by | for two-column layout
|
||||
if (text.includes('|')) {
|
||||
const parts = text.split('|').map(p => p.trim());
|
||||
return (
|
||||
<div key={i} className="flex items-center justify-between text-[11px] py-0.5">
|
||||
<span className="text-slate-600">{parts[0]}</span>
|
||||
<span className="text-slate-600">{parts[1]}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5 text-[11px] py-0.5">
|
||||
<span className="w-1 h-1 rounded-full bg-slate-300 flex-shrink-0" />
|
||||
<span className="text-slate-600">{text}</span>
|
||||
<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">{displayCandidates.length}/{s.candidates.length} 辆</span>
|
||||
<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 */}
|
||||
@@ -222,67 +271,28 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{displayCandidates.map(c => {
|
||||
const sent = sentPlates.has(c.plateNumber);
|
||||
return (
|
||||
<div key={c.plateNumber} className="rounded-xl border border-slate-200 overflow-hidden bg-white">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{c.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} />{c.region}</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">
|
||||
<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">
|
||||
<AlertTriangle size={10} /> 需关注
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{sameRegion.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{sameRegion.map(c => renderCandidate(c))}
|
||||
</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(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>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action */}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{displayCount === 0 && (
|
||||
<div className="py-8 text-center text-xs text-slate-400">当前筛选条件下无可替换车辆</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,62 +1,11 @@
|
||||
export interface SchedulingVehicleInfo {
|
||||
plateNumber: string;
|
||||
targetId: number;
|
||||
targetName: string;
|
||||
vehicleType: string;
|
||||
totalMileage: number;
|
||||
currentYearMileage: number;
|
||||
completionRate: number;
|
||||
yearTarget: number;
|
||||
region: string;
|
||||
province: string;
|
||||
customer: string | null;
|
||||
department: string | null;
|
||||
manager: string | null;
|
||||
customerAvgDaily: number;
|
||||
predictedYearEnd: number;
|
||||
daysLeft: number;
|
||||
}
|
||||
|
||||
export interface CandidateVehicle {
|
||||
plateNumber: string;
|
||||
targetId: number | null;
|
||||
targetName: string | null;
|
||||
vehicleType: string;
|
||||
totalMileage: number;
|
||||
completionRate: number;
|
||||
yearTarget: number | null;
|
||||
daysLeft: number;
|
||||
region: string;
|
||||
province: string;
|
||||
mileageGap: number;
|
||||
predictedAfterSwap: number;
|
||||
canQualifyAfterSwap: boolean;
|
||||
}
|
||||
|
||||
export interface SchedulingSuggestion {
|
||||
id: string;
|
||||
priority: 'high' | 'medium';
|
||||
type: 'replace_qualified' | 'rescue_hopeless';
|
||||
currentVehicle: SchedulingVehicleInfo;
|
||||
candidates: CandidateVehicle[];
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface SchedulingSummary {
|
||||
qualifiedCount: number;
|
||||
hopelessCount: number;
|
||||
suggestionCount: number;
|
||||
estimatedGain: number;
|
||||
}
|
||||
|
||||
export interface SchedulingTargetOption {
|
||||
id: number;
|
||||
name: string;
|
||||
vehicleCount: number;
|
||||
}
|
||||
|
||||
export interface SchedulingResponse {
|
||||
summary: SchedulingSummary;
|
||||
suggestions: SchedulingSuggestion[];
|
||||
targets: SchedulingTargetOption[];
|
||||
}
|
||||
export type {
|
||||
SchedulingVehicleInfo,
|
||||
CandidateVehicle,
|
||||
SchedulingSuggestion,
|
||||
SchedulingSummary,
|
||||
SchedulingTargetOption,
|
||||
SchedulingResponse,
|
||||
NotifyRequest,
|
||||
ReasonLine,
|
||||
ReasonBlock,
|
||||
} from '../../shared/scheduling/types';
|
||||
|
||||
Reference in New Issue
Block a user