feat(scheduling): add full-screen SwapPreview for screenshot sharing
New SwapPreview component replaces direct "发送通知" button: - Full-screen white background for clean screenshots - Swap diagram: current vehicle → arrow → replacement vehicle - Replacement reason section - Post-swap prediction: predicted mileage, target, conclusion - "发送替换通知" button at bottom - Candidate button in detail modal changed to "查看替换方案 →" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
X, MapPin, AlertTriangle, CheckCircle, Send, ArrowDown, ArrowUp,
|
X, MapPin, AlertTriangle, CheckCircle, ArrowDown, ArrowUp, ArrowRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { sendNotify } from './api';
|
|
||||||
import type { SchedulingSuggestion, CandidateVehicle } from './types';
|
import type { SchedulingSuggestion, CandidateVehicle } from './types';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
import SwapPreview from './SwapPreview';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
suggestion: SchedulingSuggestion;
|
suggestion: SchedulingSuggestion;
|
||||||
@@ -23,34 +23,12 @@ function fmtRate(rate: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SuggestionDetail({ suggestion: s, onClose, onNotifySuccess }: Props) {
|
export default function SuggestionDetail({ suggestion: s, onClose, onNotifySuccess }: Props) {
|
||||||
const [sending, setSending] = useState(false);
|
const [previewCandidate, setPreviewCandidate] = useState<CandidateVehicle | null>(null);
|
||||||
const [sentPlates, setSentPlates] = useState<Set<string>>(new Set());
|
const [sentPlates, setSentPlates] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const v = s.currentVehicle;
|
const v = s.currentVehicle;
|
||||||
const isRescue = s.type === 'rescue_hopeless';
|
const isRescue = s.type === 'rescue_hopeless';
|
||||||
|
|
||||||
const handleNotify = async (candidate: CandidateVehicle) => {
|
|
||||||
if (sending || sentPlates.has(candidate.plateNumber)) return;
|
|
||||||
setSending(true);
|
|
||||||
try {
|
|
||||||
const result = await sendNotify({
|
|
||||||
suggestionId: s.id,
|
|
||||||
currentPlate: v.plateNumber,
|
|
||||||
candidatePlate: candidate.plateNumber,
|
|
||||||
});
|
|
||||||
if (result.success) {
|
|
||||||
setSentPlates(prev => new Set(prev).add(candidate.plateNumber));
|
|
||||||
onNotifySuccess();
|
|
||||||
} else {
|
|
||||||
alert(result.message || '发送失败');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert('网络错误,请重试');
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-end sm:items-center justify-center" onClick={onClose}>
|
<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
|
<motion.div
|
||||||
@@ -178,15 +156,15 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
|||||||
{/* Action */}
|
{/* Action */}
|
||||||
<div className="px-3 pb-2.5">
|
<div className="px-3 pb-2.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleNotify(c)}
|
onClick={() => setPreviewCandidate(c)}
|
||||||
disabled={sending || sent}
|
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 ${
|
className={`w-full flex items-center justify-center gap-1.5 text-[11px] font-bold py-2 rounded-lg transition-all cursor-pointer ${
|
||||||
sent
|
sent
|
||||||
? 'bg-slate-100 text-slate-400'
|
? 'bg-emerald-50 text-emerald-600'
|
||||||
: 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-sm'
|
: 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-sm'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{sent ? <><CheckCircle size={12} /> 已发送</> : <><Send size={12} /> 发送替换通知</>}
|
{sent ? <><CheckCircle size={12} /> 已通知</> : <>查看替换方案 <ArrowRight size={12} /></>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,6 +184,20 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
175
src/modules/scheduling/SwapPreview.tsx
Normal file
175
src/modules/scheduling/SwapPreview.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ArrowDown, CheckCircle, Send, X } from 'lucide-react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { sendNotify } 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 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 v = s.currentVehicle;
|
||||||
|
const isRescue = s.type === 'rescue_hopeless';
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (sending || sent) 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[80] bg-white flex flex-col">
|
||||||
|
{/* Top bar */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 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 — screenshot-friendly, no scroll needed */}
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center px-6 py-4 gap-4 overflow-auto">
|
||||||
|
|
||||||
|
{/* Swap type badge */}
|
||||||
|
<div className={`text-xs font-bold px-3 py-1 rounded-full ${
|
||||||
|
isRescue ? 'bg-blue-50 text-blue-700' : 'bg-orange-50 text-orange-700'
|
||||||
|
}`}>
|
||||||
|
{isRescue ? '里程低·换走此车' : '里程高·换下此车'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* === Swap Diagram === */}
|
||||||
|
<div className="w-full max-w-sm space-y-3">
|
||||||
|
|
||||||
|
{/* Current vehicle (换下/换走) */}
|
||||||
|
<div className={`rounded-2xl p-4 border ${isRescue ? 'bg-blue-50 border-blue-200' : 'bg-orange-50 border-orange-200'}`}>
|
||||||
|
<div className="text-[10px] font-bold text-slate-400 uppercase mb-2">
|
||||||
|
{isRescue ? '换走车辆' : '换下车辆'}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center 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-500 mt-0.5">{v.vehicleType} · {v.targetName}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-black text-slate-700">{fmtKm(v.currentYearMileage)} <span className="text-[9px] text-slate-400">km</span></div>
|
||||||
|
<div className="text-[10px] text-slate-400">考核 {fmtKm(v.yearTarget)} km</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-[10px] text-slate-500">
|
||||||
|
<span>客户 <b className="text-slate-700"><Blur>{v.customer || '-'}</Blur></b></span>
|
||||||
|
<span>日均 <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b> km</span>
|
||||||
|
<span>完成 <b className={v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}>{fmtRate(v.completionRate)}</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center shadow-lg">
|
||||||
|
<ArrowDown size={18} className="text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Replacement vehicle (换上) */}
|
||||||
|
<div className="rounded-2xl p-4 border bg-emerald-50 border-emerald-200">
|
||||||
|
<div className="text-[10px] font-bold text-slate-400 uppercase mb-2">
|
||||||
|
换上车辆
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center 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-500 mt-0.5">{c.vehicleType} · {c.targetName || '库存'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-black text-slate-700">{fmtKm(c.totalMileage)} <span className="text-[9px] text-slate-400">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-2 mt-2 text-[10px] text-slate-500">
|
||||||
|
<span>缺口 <b className="text-rose-500">{fmtKm(c.mileageGap)}</b> km</span>
|
||||||
|
<span>区域 <b className="text-slate-700">{c.region}</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* === Reason === */}
|
||||||
|
<div className="w-full max-w-sm bg-slate-50 rounded-xl p-3 border border-slate-200">
|
||||||
|
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1">替换原因</div>
|
||||||
|
<div className="text-xs text-slate-700 leading-relaxed whitespace-pre-line">{s.reason}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* === Conclusion === */}
|
||||||
|
<div className="w-full max-w-sm bg-emerald-50 rounded-xl p-3 border border-emerald-200">
|
||||||
|
<div className="text-[10px] font-bold text-emerald-600 uppercase mb-1">替换后预测</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-600">
|
||||||
|
<Blur>{c.plateNumber}</Blur> 换到客户「<Blur>{v.customer || '-'}</Blur>」后
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-slate-400">预测年终</div>
|
||||||
|
<div className="text-base font-black text-emerald-700">{fmtKm(c.predictedAfterSwap)} km</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-slate-400">考核目标</div>
|
||||||
|
<div className="text-base font-black text-slate-700">{c.yearTarget ? fmtKm(c.yearTarget) : '-'} km</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-slate-400">结论</div>
|
||||||
|
<div className={`text-base font-black ${c.canQualifyAfterSwap ? 'text-emerald-600' : 'text-amber-600'}`}>
|
||||||
|
{c.canQualifyAfterSwap ? '可达标' : '需关注'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom action */}
|
||||||
|
<div className="px-6 pb-6 pt-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending || sent}
|
||||||
|
className={`w-full flex items-center justify-center gap-2 py-3 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user