Files
ln-bi/src/modules/scheduling/SwapPreview.tsx
kkfluous a954fb90f6
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
refactor(scheduling): 考核剩余 → 年度考核剩余
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 11:04:08 +08:00

170 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { ArrowDownUp, CheckCircle, Send, X, Ban } from 'lucide-react';
import { sendNotify, updateNotification } from './api';
import type { SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur';
interface Props {
suggestion: SchedulingSuggestion;
candidate: CandidateVehicle;
onClose: () => void;
onSuccess: () => void;
}
function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(1) + '万';
return Math.round(value).toLocaleString();
}
function fmtRate(rate: number): string {
return (rate * 100).toFixed(1) + '%';
}
export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSuccess }: Props) {
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [cancelling, setCancelling] = useState(false);
const v = s.currentVehicle;
const alreadyIntervened =
!sent && (c.notificationStatus === 'sent' || c.notificationStatus === 'executed');
const isExecuted = c.notificationStatus === 'executed';
const handleSend = async () => {
if (sending || sent || alreadyIntervened) return;
setSending(true);
try {
const result = await sendNotify({ suggestionId: s.id, currentPlate: v.plateNumber, candidatePlate: c.plateNumber });
if (result.success) { setSent(true); onSuccess(); } else { alert(result.message || '发送失败'); }
} catch { alert('网络错误'); } finally { setSending(false); }
};
const handleCancel = async () => {
if (!c.notificationId || cancelling) return;
if (isExecuted) {
if (!confirm('此干预已标记为执行。确定要取消吗?')) return;
} else {
if (!confirm(`确定取消 ${v.plateNumber}${c.plateNumber} 的干预?`)) return;
}
setCancelling(true);
try {
const result = await updateNotification(c.notificationId, { status: 'cancelled' });
if (result.success) { onSuccess(); onClose(); }
else { alert(result.message || '取消失败'); }
} catch { alert('网络错误'); } finally { setCancelling(false); }
};
return (
<div className="fixed inset-0 z-[80] bg-[#F0F4F8] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 bg-white border-b border-slate-200 flex-shrink-0">
<span className="text-sm font-bold text-slate-800"></span>
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-600 cursor-pointer"><X size={20} /></button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto px-5 py-5">
<div className="max-w-sm mx-auto space-y-4">
{/* === Swap Cards === */}
<div className="relative">
{/* Current vehicle */}
<div className="bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
<div className="flex items-start justify-between">
<div>
<div className="text-lg font-black text-slate-900 font-mono"><Blur>{v.plateNumber}</Blur></div>
<div className="text-[10px] text-slate-400 mt-0.5">{v.vehicleType} · {v.targetName}</div>
</div>
<div className="text-right">
<div className="text-base font-black text-slate-800">{fmtKm(v.currentYearMileage)}<span className="text-[9px] text-slate-400 ml-0.5">km</span></div>
<div className="text-[10px] text-slate-400"> {fmtKm(v.yearTarget)} km</div>
<div className="text-[10px] text-slate-400"> <b className="text-slate-700">{v.daysLeft}</b> </div>
</div>
</div>
<div className="flex items-center gap-3 mt-2.5 text-[10px] text-slate-500">
<span><Blur>{v.customer || '-'}</Blur></span>
<span> <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b></span>
<span> <b className={v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}>{fmtRate(v.completionRate)}</b></span>
</div>
</div>
{/* Arrow bridge */}
<div className="flex justify-center -my-3 relative z-10">
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center shadow-lg">
<ArrowDownUp size={16} className="text-white" />
</div>
</div>
{/* Replacement vehicle */}
<div className="bg-white rounded-2xl p-4 border border-emerald-300 shadow-sm">
<div className="flex items-start justify-between">
<div>
<div className="text-lg font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></div>
<div className="text-[10px] text-slate-400 mt-0.5">{c.vehicleType} · {c.targetName || '库存'} · {c.region}</div>
</div>
<div className="text-right">
<div className="text-base font-black text-slate-800">{fmtKm(c.totalMileage)}<span className="text-[9px] text-slate-400 ml-0.5">km</span></div>
<div className="text-[10px] text-slate-400"> {c.yearTarget ? fmtKm(c.yearTarget) : '-'} km</div>
</div>
</div>
<div className="flex items-center gap-3 mt-2.5 text-[10px] text-slate-500">
<span> <b className="text-rose-500">{fmtKm(c.mileageGap)}</b></span>
</div>
</div>
</div>
{/* === Result === */}
<div className="bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-3"></div>
<div className="flex items-end gap-6">
<div>
<div className="text-[9px] text-slate-400 mb-0.5"></div>
<div className="text-xl font-black text-slate-800">{fmtKm(c.predictedAfterSwap)} <span className="text-[10px] font-normal text-slate-400">km</span></div>
</div>
<div>
<div className="text-[9px] text-slate-400 mb-0.5"></div>
<div className="text-xl font-black text-slate-800">{c.yearTarget ? fmtKm(c.yearTarget) : '-'} <span className="text-[10px] font-normal text-slate-400">km</span></div>
</div>
<div className={`text-sm font-black px-3 py-1 rounded-lg ${c.canQualifyAfterSwap ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600'}`}>
{c.canQualifyAfterSwap ? '可达标' : '需关注'}
</div>
</div>
</div>
</div>
</div>
{/* Bottom */}
<div className="px-5 pb-6 pt-2 flex-shrink-0 bg-[#F0F4F8]">
<div className="max-w-sm mx-auto space-y-2">
{alreadyIntervened && (
<div className="rounded-xl bg-emerald-50 border border-emerald-200 px-3 py-2 text-[11px] text-emerald-700 flex items-center gap-2">
<CheckCircle size={13} />
<span>{isExecuted ? '执行干预' : '登记干预'}</span>
</div>
)}
{alreadyIntervened ? (
<button
onClick={handleCancel}
disabled={cancelling}
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-bold bg-white text-rose-600 border border-rose-200 hover:bg-rose-50 active:scale-[0.98] transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<Ban size={16} /> {cancelling ? '取消中...' : '取消干预'}
</button>
) : (
<button
onClick={handleSend}
disabled={sending || sent}
className={`w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-bold transition-all cursor-pointer ${
sent ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-lg'
}`}
>
{sent ? <><CheckCircle size={16} /> </> : <><Send size={16} /> </>}
</button>
)}
</div>
</div>
</div>
);
}