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:
kkfluous
2026-04-16 23:36:38 +08:00
parent 335282a2c3
commit 31716c6547
6 changed files with 250 additions and 240 deletions

View File

@@ -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>();

View File

@@ -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>

View File

@@ -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';