All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
详情页的指标区从单列两格网格改为 左:客户 / 右:车辆 两栏。客户日均归 左侧,考核剩余、日均需、年度完成率、可为新车贡献归右侧,便于一眼 识别数据归属。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
379 lines
18 KiB
TypeScript
379 lines
18 KiB
TypeScript
import { useState, useMemo } from 'react';
|
||
import {
|
||
X, MapPin, AlertTriangle, CheckCircle, ArrowDown, ArrowUp, ArrowRight, ArrowUpDown, Lock,
|
||
} from 'lucide-react';
|
||
import { motion } from 'motion/react';
|
||
import type { SchedulingSuggestion, CandidateVehicle } from './types';
|
||
import Blur from '../../components/Blur';
|
||
import SwapPreview from './SwapPreview';
|
||
|
||
type SortKey = 'predicted' | 'current';
|
||
type SortDir = 'asc' | 'desc';
|
||
|
||
interface Props {
|
||
suggestion: SchedulingSuggestion;
|
||
onClose: () => void;
|
||
onNotifySuccess: () => 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) + '%';
|
||
}
|
||
|
||
const CUSTOMER_REASON_LABELS = new Set(['客户日均']);
|
||
|
||
export default function SuggestionDetail({ suggestion: s, onClose, onNotifySuccess }: Props) {
|
||
const [previewCandidate, setPreviewCandidate] = useState<CandidateVehicle | null>(null);
|
||
const [sentPlates, setSentPlates] = useState<Set<string>>(new Set());
|
||
const [batchFilter, setBatchFilter] = useState<Set<string>>(new Set());
|
||
const [sortKey, setSortKey] = useState<SortKey>('predicted');
|
||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||
|
||
const v = s.currentVehicle;
|
||
const isRescue = s.type === 'rescue_hopeless';
|
||
|
||
// Business rule: a current vehicle can have AT MOST ONE active intervention.
|
||
// Find the active candidate (if any) — other candidates are blocked until
|
||
// this one is cancelled.
|
||
const activeIntervention = s.candidates.find(
|
||
cc => cc.notificationStatus === 'sent' || cc.notificationStatus === 'executed',
|
||
);
|
||
|
||
// Batch options from candidates
|
||
const batchOptions = useMemo(() => {
|
||
const set = new Set<string>();
|
||
for (const c of s.candidates) if (c.targetName) set.add(c.targetName);
|
||
return [...set].sort();
|
||
}, [s.candidates]);
|
||
|
||
// 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));
|
||
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) ||
|
||
c.notificationStatus === 'sent' ||
|
||
c.notificationStatus === 'executed';
|
||
const blockedByOther = !!activeIntervention && activeIntervention.plateNumber !== c.plateNumber;
|
||
return (
|
||
<div key={c.plateNumber} className={`rounded-xl border overflow-hidden bg-white ${blockedByOther ? 'border-slate-200 opacity-60' : 'border-slate-200'}`}>
|
||
<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">
|
||
{blockedByOther ? (
|
||
<div className="w-full flex items-center justify-center gap-1.5 text-[11px] font-medium py-2 rounded-lg bg-slate-50 text-slate-400 cursor-not-allowed">
|
||
<Lock size={11} /> 该车已有其他干预,请先解除
|
||
</div>
|
||
) : (
|
||
<button
|
||
onClick={() => setPreviewCandidate(c)}
|
||
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 hover:bg-emerald-100 text-emerald-700 border border-emerald-200'
|
||
: 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-sm'
|
||
}`}
|
||
>
|
||
{sent ? <><CheckCircle size={12} /> 已干预 · 查看 <ArrowRight 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
|
||
initial={{ y: 40, opacity: 0 }}
|
||
animate={{ y: 0, opacity: 1 }}
|
||
onClick={e => e.stopPropagation()}
|
||
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-lg overflow-hidden flex flex-col max-h-[92vh] sm:max-h-[85vh] sm:mx-4"
|
||
>
|
||
{/* Header — unified dark slate */}
|
||
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
|
||
<div className="flex items-center gap-2">
|
||
{isRescue
|
||
? <ArrowDown size={14} className="text-blue-300" />
|
||
: <ArrowUp size={14} className="text-amber-300" />
|
||
}
|
||
<span className="text-white font-bold text-sm">
|
||
{isRescue ? '里程低·换走此车' : '里程高·换下此车'}
|
||
</span>
|
||
</div>
|
||
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors p-1 cursor-pointer">
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div className="overflow-y-auto flex-1 no-scrollbar">
|
||
|
||
{/* Current Vehicle — same format as candidate cards */}
|
||
<div className="px-4 py-3 border-b border-slate-100">
|
||
<div className="rounded-xl border border-slate-200 overflow-hidden bg-white">
|
||
{/* Header — same style as candidate header */}
|
||
<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>{v.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} />{v.region}</span>
|
||
<span className="text-[9px] text-slate-400">{v.vehicleType}</span>
|
||
<span className="text-[9px] text-slate-300">{v.targetName}</span>
|
||
<span className="text-[9px] text-slate-400">剩余{v.daysLeft}天</span>
|
||
</div>
|
||
<span className={`text-sm font-black tabular-nums ${v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}`}>
|
||
{fmtRate(v.completionRate)}
|
||
</span>
|
||
</div>
|
||
{/* Customer + dept/manager info */}
|
||
<div className="px-3 pb-1.5 flex items-center gap-2 text-[10px] text-slate-500 flex-wrap">
|
||
{v.department && <span><b className="text-slate-700">{v.department}</b></span>}
|
||
{v.manager && <span><b className="text-slate-700">{v.manager}</b></span>}
|
||
{(v.department || v.manager) && <span className="text-slate-200">|</span>}
|
||
<span>客户 <b className="text-slate-700"><Blur>{v.customer || '-'}</Blur></b></span>
|
||
<span>
|
||
30日均 <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b> km
|
||
</span>
|
||
</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(v.currentYearMileage)}</div>
|
||
</div>
|
||
<div className="flex-1 py-1.5 px-2 text-center">
|
||
<div className="text-slate-400">考核期结束预估</div>
|
||
<div className={`font-bold ${v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}`}>{fmtKm(v.currentYearMileage + v.customerAvgDaily * v.daysLeft)}</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">{fmtKm(v.yearTarget)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Reason — customer vs vehicle columns */}
|
||
<div className="px-4 py-2.5 border-b border-slate-100 bg-slate-50/60">
|
||
<div className="grid grid-cols-2 gap-x-5">
|
||
{(() => {
|
||
const customerLines = s.reason.lines.filter(l => CUSTOMER_REASON_LABELS.has(l.label));
|
||
const vehicleLines = s.reason.lines.filter(l => !CUSTOMER_REASON_LABELS.has(l.label));
|
||
return (
|
||
<>
|
||
<div>
|
||
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider mb-1">客户</div>
|
||
<div className="space-y-1">
|
||
{customerLines.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>
|
||
<div>
|
||
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider mb-1">车辆</div>
|
||
<div className="space-y-1">
|
||
{vehicleLines.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>
|
||
</>
|
||
);
|
||
})()}
|
||
</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">{displayCount}/{s.candidates.length} 辆</span>
|
||
</div>
|
||
|
||
{activeIntervention && (
|
||
<div className="mb-2.5 flex items-start gap-2 rounded-lg bg-emerald-50 border border-emerald-200 px-3 py-2 text-[11px] text-emerald-800">
|
||
<Lock size={12} className="mt-0.5 flex-shrink-0" />
|
||
<span>
|
||
此车已干预替换为 <b className="font-mono"><Blur>{activeIntervention.plateNumber}</Blur></b>。如需更换方案,请先在该候选车处解除干预。
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Filter + Sort controls */}
|
||
<div className="flex items-center gap-2 mb-2.5 flex-wrap">
|
||
{/* Batch multi-select pills */}
|
||
<div className="flex items-center gap-1.5 flex-wrap">
|
||
<button
|
||
onClick={() => setBatchFilter(new Set())}
|
||
className={`text-[10px] px-2 py-1 rounded-lg border cursor-pointer transition-colors ${
|
||
batchFilter.size === 0 ? 'border-blue-300 bg-blue-50 text-blue-700 font-bold' : 'border-slate-200 text-slate-500'
|
||
}`}
|
||
>
|
||
全部
|
||
</button>
|
||
{batchOptions.map(b => {
|
||
const active = batchFilter.has(b);
|
||
return (
|
||
<button
|
||
key={b}
|
||
onClick={() => setBatchFilter(prev => {
|
||
const next = new Set(prev);
|
||
if (active) next.delete(b); else next.add(b);
|
||
return next;
|
||
})}
|
||
className={`text-[10px] px-2 py-1 rounded-lg border cursor-pointer transition-colors ${
|
||
active ? 'border-blue-300 bg-blue-50 text-blue-700 font-bold' : 'border-slate-200 text-slate-500'
|
||
}`}
|
||
>
|
||
{b}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Sort buttons */}
|
||
<button
|
||
onClick={() => toggleSort('predicted')}
|
||
className={`text-[10px] px-2 py-1 rounded-lg border flex items-center gap-1 cursor-pointer transition-colors ${
|
||
sortKey === 'predicted' ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'
|
||
}`}
|
||
>
|
||
替换后预计
|
||
{sortKey === 'predicted' && (sortDir === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />)}
|
||
{sortKey !== 'predicted' && <ArrowUpDown size={10} />}
|
||
</button>
|
||
<button
|
||
onClick={() => toggleSort('current')}
|
||
className={`text-[10px] px-2 py-1 rounded-lg border flex items-center gap-1 cursor-pointer transition-colors ${
|
||
sortKey === 'current' ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'
|
||
}`}
|
||
>
|
||
当前里程
|
||
{sortKey === 'current' && (sortDir === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />)}
|
||
{sortKey !== 'current' && <ArrowUpDown size={10} />}
|
||
</button>
|
||
</div>
|
||
|
||
{sameRegion.length > 0 && (
|
||
<div className="space-y-2">
|
||
{sameRegion.map(c => renderCandidate(c))}
|
||
</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>
|
||
</>
|
||
)}
|
||
|
||
{displayCount === 0 && (
|
||
<div className="py-8 text-center text-xs text-slate-400">当前筛选条件下无可替换车辆</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="border-t border-slate-100 px-4 py-2.5 flex-shrink-0">
|
||
<button
|
||
onClick={onClose}
|
||
className="w-full py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors cursor-pointer"
|
||
>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
|
||
{/* Full-screen swap preview */}
|
||
{previewCandidate && (
|
||
<SwapPreview
|
||
suggestion={s}
|
||
candidate={previewCandidate}
|
||
onClose={() => setPreviewCandidate(null)}
|
||
onSuccess={() => {
|
||
setSentPlates(prev => new Set(prev).add(previewCandidate.plateNumber));
|
||
setPreviewCandidate(null);
|
||
onNotifySuccess();
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|