diff --git a/src/modules/scheduling/NotificationHistory.tsx b/src/modules/scheduling/NotificationHistory.tsx new file mode 100644 index 0000000..69d8b2d --- /dev/null +++ b/src/modules/scheduling/NotificationHistory.tsx @@ -0,0 +1,272 @@ +import { useCallback, useEffect, useState } from 'react'; +import { X, RotateCcw, Clock, CheckCircle2, XCircle, Send, Loader2 } from 'lucide-react'; +import { motion, AnimatePresence } from 'motion/react'; +import { fetchNotifications, updateNotification } from './api'; +import type { NotificationRecord, NotificationStatus } from './types'; +import Blur from '../../components/Blur'; + +interface Props { + onClose: () => void; + onChange?: () => void; +} + +type StatusTab = 'all' | NotificationStatus; + +const STATUS_TABS: { key: StatusTab; label: string }[] = [ + { key: 'all', label: '全部' }, + { key: 'sent', label: '待执行' }, + { key: 'executed', label: '已执行' }, + { key: 'cancelled', label: '已取消' }, +]; + +function statusBadge(status: NotificationStatus) { + if (status === 'sent') return { text: '待执行', icon: , cls: 'text-amber-700 bg-amber-50' }; + if (status === 'executed') return { text: '已执行', icon: , cls: 'text-emerald-700 bg-emerald-50' }; + return { text: '已取消', icon: , cls: 'text-slate-500 bg-slate-100' }; +} + +function fmtDateTime(iso: string): string { + if (!iso) return ''; + const d = new Date(iso); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + return `${y}-${m}-${day} ${hh}:${mm}`; +} + +export default function NotificationHistory({ onClose, onChange }: Props) { + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(false); + const [tab, setTab] = useState('all'); + const [mutatingId, setMutatingId] = useState(null); + const [executeTarget, setExecuteTarget] = useState(null); + const [afterMileageInput, setAfterMileageInput] = useState(''); + const [notesInput, setNotesInput] = useState(''); + + const load = useCallback(async () => { + setLoading(true); + try { + const resp = await fetchNotifications(tab === 'all' ? undefined : tab); + setRecords(resp.records); + } finally { + setLoading(false); + } + }, [tab]); + + useEffect(() => { load(); }, [load]); + + const handleExecuteClick = (rec: NotificationRecord) => { + setExecuteTarget(rec); + setAfterMileageInput(''); + setNotesInput(''); + }; + + const handleExecuteConfirm = async () => { + if (!executeTarget) return; + setMutatingId(executeTarget.id); + try { + const body: { status: NotificationStatus; notes?: string; afterMileage?: number } = { status: 'executed' }; + if (notesInput.trim()) body.notes = notesInput.trim(); + const parsed = Number(afterMileageInput); + if (Number.isFinite(parsed) && parsed > 0) body.afterMileage = parsed; + await updateNotification(executeTarget.id, body); + setExecuteTarget(null); + await load(); + onChange?.(); + } finally { + setMutatingId(null); + } + }; + + const handleCancel = async (rec: NotificationRecord) => { + if (!confirm(`确定取消 ${rec.currentPlate} → ${rec.candidatePlate} 的替换通知?`)) return; + setMutatingId(rec.id); + try { + await updateNotification(rec.id, { status: 'cancelled' }); + await load(); + onChange?.(); + } finally { + setMutatingId(null); + } + }; + + return ( + + e.stopPropagation()} + className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-xl overflow-hidden flex flex-col max-h-[85vh] sm:mx-4" + > + {/* Header */} + + + + 调度记录 + + + + + + + + + + + + {/* Status tabs */} + + {STATUS_TABS.map(t => ( + setTab(t.key)} + className={`text-[11px] px-3 py-1 rounded-full font-medium cursor-pointer transition-colors ${ + tab === t.key ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200' + }`} + > + {t.label} + + ))} + + + {/* Body */} + + {loading && records.length === 0 ? ( + + 加载中 + + ) : records.length === 0 ? ( + + + 暂无记录 + + ) : ( + + {records.map(rec => { + const badge = statusBadge(rec.status); + const busy = mutatingId === rec.id; + return ( + + + + {rec.currentPlate} + → + {rec.candidatePlate} + + + {badge.icon} {badge.text} + + + + {rec.operatorName && 操作人 {rec.operatorName}} + {fmtDateTime(rec.createdAt)} + {rec.status === 'executed' && rec.executedAt && ( + 执行 {fmtDateTime(rec.executedAt)} + )} + + {rec.notes && ( + {rec.notes} + )} + {rec.status === 'sent' && ( + + handleExecuteClick(rec)} + disabled={busy} + className="text-[10px] font-bold text-white bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 px-2.5 py-1 rounded cursor-pointer transition-colors flex items-center gap-1" + > + 标记已执行 + + handleCancel(rec)} + disabled={busy} + className="text-[10px] font-medium text-slate-500 hover:text-rose-600 disabled:opacity-50 px-2 py-1 rounded cursor-pointer transition-colors" + > + 取消 + + + )} + + ); + })} + + )} + + + + {/* Execute confirmation modal */} + + {executeTarget && ( + mutatingId === null && setExecuteTarget(null)} + > + e.stopPropagation()} + className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-sm overflow-hidden flex flex-col sm:mx-4" + > + + 确认已执行 + mutatingId === null && setExecuteTarget(null)} + disabled={mutatingId !== null} + className="text-emerald-100 hover:text-white p-1 cursor-pointer disabled:opacity-50" + > + + + + + + {executeTarget.currentPlate} + → + {executeTarget.candidatePlate} + + + 执行后里程 (km, 可选) + setAfterMileageInput(e.target.value)} + placeholder="例如 45230" + className="w-full px-3 py-2 text-xs bg-slate-50 rounded-lg border border-slate-200 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-400 transition-all" + /> + + + 备注 (可选) + setNotesInput(e.target.value)} + rows={2} + placeholder="例如:司机已到位,交接完成" + className="w-full px-3 py-2 text-xs bg-slate-50 rounded-lg border border-slate-200 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-400 transition-all resize-none" + /> + + + + setExecuteTarget(null)} + disabled={mutatingId !== null} + className="flex-1 py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg cursor-pointer disabled:opacity-50 transition-colors" + > + 取消 + + + {mutatingId !== null ? '保存中...' : '确认'} + + + + + )} + + + ); +} diff --git a/src/modules/scheduling/SchedulingModule.tsx b/src/modules/scheduling/SchedulingModule.tsx index 8fc0d0e..edc19df 100644 --- a/src/modules/scheduling/SchedulingModule.tsx +++ b/src/modules/scheduling/SchedulingModule.tsx @@ -1,10 +1,12 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send } from 'lucide-react'; +import { Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { fetchSuggestions, sendNotifyBatch } from './api'; import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types'; import SuggestionList from './SuggestionList'; import SuggestionDetail from './SuggestionDetail'; +import NotificationHistory from './NotificationHistory'; +import { exportSuggestionsCsv } from './csv-export'; import Blur from '../../components/Blur'; type TypeFilter = 'all' | 'qualified' | 'hopeless'; @@ -165,6 +167,7 @@ export default function SchedulingModule() { const [showBatchConfirm, setShowBatchConfirm] = useState(false); const [batchInFlight, setBatchInFlight] = useState(false); const [batchResultMsg, setBatchResultMsg] = useState(null); + const [showHistory, setShowHistory] = useState(false); const loadData = useCallback(async () => { setLoading(true); @@ -346,6 +349,21 @@ export default function SchedulingModule() { className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer"> + exportSuggestionsCsv(filteredSuggestions)} + disabled={filteredSuggestions.length === 0} + className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed" + title="导出 CSV" + > + + + setShowHistory(true)} + className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer" + title="调度记录" + > + + { if (selectMode) exitSelectMode(); @@ -486,6 +504,10 @@ export default function SchedulingModule() { setSelectedSuggestion(null)} onNotifySuccess={handleNotifySuccess} /> )} + {showHistory && ( + setShowHistory(false)} onChange={loadData} /> + )} + {/* Batch action bar */} {selectMode && ( diff --git a/src/modules/scheduling/SuggestionDetail.tsx b/src/modules/scheduling/SuggestionDetail.tsx index 5aadcc3..71e922f 100644 --- a/src/modules/scheduling/SuggestionDetail.tsx +++ b/src/modules/scheduling/SuggestionDetail.tsx @@ -1,6 +1,7 @@ import { useState, useMemo } from 'react'; import { X, MapPin, AlertTriangle, CheckCircle, ArrowDown, ArrowUp, ArrowRight, ArrowUpDown, + TrendingUp, TrendingDown, Minus, } from 'lucide-react'; import { motion } from 'motion/react'; import type { SchedulingSuggestion, CandidateVehicle } from './types'; @@ -175,7 +176,28 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce {v.manager && {v.manager}} {(v.department || v.manager) && |} 客户 {v.customer || '-'} - 日均 {Math.round(v.customerAvgDaily)} km + + 30日均 {Math.round(v.customerAvgDaily)} km + {v.customerAvgDaily > 0 && v.customerAvgDaily7d > 0 && (() => { + const diff = (v.customerAvgDaily7d - v.customerAvgDaily) / v.customerAvgDaily; + const pct = Math.round(diff * 100); + if (diff >= 0.1) return ( + + 7日 +{pct}% + + ); + if (diff <= -0.1) return ( + + 7日 {pct}% + + ); + return ( + + 7日平稳 + + ); + })()} + {/* Metrics */} diff --git a/src/modules/scheduling/SuggestionList.tsx b/src/modules/scheduling/SuggestionList.tsx index 3468b84..9fa62cd 100644 --- a/src/modules/scheduling/SuggestionList.tsx +++ b/src/modules/scheduling/SuggestionList.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from 'react'; -import { ArrowRightLeft, ChevronRight, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, Check } from 'lucide-react'; +import { ArrowRightLeft, ChevronRight, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, Check, TrendingUp, TrendingDown } from 'lucide-react'; import { motion } from 'motion/react'; import type { SchedulingSuggestion } from './types'; import Blur from '../../components/Blur'; @@ -149,7 +149,15 @@ export default function SuggestionList({ suggestions, onSelect, selectMode = fal {v.customer || '-'} - 客户日均 {Math.round(v.customerAvgDaily)} km + + 客户日均 {Math.round(v.customerAvgDaily)} km + {v.customerAvgDaily > 0 && v.customerAvgDaily7d > 0 && (() => { + const diff = (v.customerAvgDaily7d - v.customerAvgDaily) / v.customerAvgDaily; + if (diff >= 0.1) return ; + if (diff <= -0.1) return ; + return null; + })()} + diff --git a/src/modules/scheduling/csv-export.ts b/src/modules/scheduling/csv-export.ts new file mode 100644 index 0000000..223d3c4 --- /dev/null +++ b/src/modules/scheduling/csv-export.ts @@ -0,0 +1,103 @@ +import type { SchedulingSuggestion, CandidateVehicle } from './types'; + +function csvCell(v: string | number | null | undefined): string { + if (v === null || v === undefined) return ''; + const s = typeof v === 'number' ? String(v) : v; + if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`; + return s; +} + +function pickTopCandidate(s: SchedulingSuggestion): CandidateVehicle | null { + if (s.candidates.length === 0) return null; + const sameRegion = s.candidates.filter(c => c.isSameRegion); + const pool = sameRegion.length > 0 ? sameRegion : s.candidates; + return pool.find(c => c.canQualifyAfterSwap) ?? pool[0]; +} + +function pctString(rate: number): string { + return (rate * 100).toFixed(1) + '%'; +} + +function typeLabel(s: SchedulingSuggestion): string { + return s.type === 'replace_qualified' ? '里程高·换下' : '里程低·换走'; +} + +const HEADERS = [ + '车牌号', + '业务部门', + '业务负责人', + '客户', + '车型', + '运营区域', + '调度类型', + '当前年里程(km)', + '年度考核(km)', + '年度完成率', + '客户30日均(km)', + '客户7日均(km)', + '剩余天数', + '最优候选车牌', + '候选当前里程(km)', + '候选替换后预估(km)', + '候选可达标', + '候选区域', + '通知状态', +] as const; + +export function buildSuggestionsCsv(suggestions: SchedulingSuggestion[]): string { + const rows: string[] = [HEADERS.map(csvCell).join(',')]; + for (const s of suggestions) { + const v = s.currentVehicle; + const top = pickTopCandidate(s); + const notifStatus = + s.candidates.find(c => c.notificationStatus === 'executed') ? '已执行' + : s.candidates.find(c => c.notificationStatus === 'sent') ? '待执行' + : ''; + rows.push([ + csvCell(v.plateNumber), + csvCell(v.department ?? ''), + csvCell(v.manager ?? ''), + csvCell(v.customer ?? ''), + csvCell(v.vehicleType), + csvCell(v.region), + csvCell(typeLabel(s)), + csvCell(Math.round(v.currentYearMileage)), + csvCell(Math.round(v.yearTarget)), + csvCell(pctString(v.completionRate)), + csvCell(Math.round(v.customerAvgDaily)), + csvCell(Math.round(v.customerAvgDaily7d)), + csvCell(v.daysLeft), + csvCell(top?.plateNumber ?? ''), + csvCell(top ? Math.round(top.totalMileage) : ''), + csvCell(top ? Math.round(top.predictedAfterSwap) : ''), + csvCell(top ? (top.canQualifyAfterSwap ? '是' : '否') : ''), + csvCell(top?.region ?? ''), + csvCell(notifStatus), + ].join(',')); + } + return rows.join('\r\n'); +} + +export function downloadCsv(filename: string, csv: string): void { + // UTF-8 BOM so Excel opens Chinese characters correctly + const blob = new Blob(['\uFEFF', csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +export function exportSuggestionsCsv(suggestions: SchedulingSuggestion[], prefix = '调度建议'): void { + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, '0'); + const d = String(now.getDate()).padStart(2, '0'); + const hh = String(now.getHours()).padStart(2, '0'); + const mm = String(now.getMinutes()).padStart(2, '0'); + const csv = buildSuggestionsCsv(suggestions); + downloadCsv(`${prefix}_${y}${m}${d}_${hh}${mm}.csv`, csv); +} diff --git a/src/server/routes/scheduling/algorithm.ts b/src/server/routes/scheduling/algorithm.ts index 1a19dc5..edf0ac6 100644 --- a/src/server/routes/scheduling/algorithm.ts +++ b/src/server/routes/scheduling/algorithm.ts @@ -62,6 +62,7 @@ export function toVehicleInfo(v: EnrichedVehicle): SchedulingVehicleInfo { department: v.department, manager: v.manager, customerAvgDaily: v.customerAvgDaily, + customerAvgDaily7d: v.customerAvgDaily7d, predictedYearEnd: v.predictedYearEnd, daysLeft: v.daysLeft, }; diff --git a/src/server/routes/scheduling/suggestions.ts b/src/server/routes/scheduling/suggestions.ts index 90f93c4..a1ad8e3 100644 --- a/src/server/routes/scheduling/suggestions.ts +++ b/src/server/routes/scheduling/suggestions.ts @@ -114,12 +114,16 @@ app.get('/', async (c) => { // ---- Collect all plates for Query 6 ---- const allPlates = assessmentRows.map((r: any) => r.plate_number as string); - // ---- Query 6: Customer daily avg (from mileage DB) ---- + // ---- Query 6: Customer daily avg (from mileage DB) — 30d baseline + 7d recent ---- const customerAvgDailyMap = new Map(); + const customerAvgDaily7dMap = new Map(); if (allPlates.length > 0) { const placeholders = allPlates.map(() => '?').join(','); + // Single query returning both windows per plate. const [dailyRows] = await mileagePool.execute( - `SELECT plate, AVG(daily_km) as avg_daily + `SELECT plate, + AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) THEN daily_km END) AS avg_30d, + AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN daily_km END) AS avg_7d FROM v_vehicle_daily_stats WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND stat_date < CURDATE() @@ -128,25 +132,30 @@ app.get('/', async (c) => { allPlates, ) as [any[], unknown]; - // Build plate → avg_daily map - const plateAvgMap = new Map(); + const plateAvg30Map = new Map(); + const plateAvg7Map = new Map(); for (const row of dailyRows) { - plateAvgMap.set(row.plate, Number(row.avg_daily) || 0); + if (row.avg_30d !== null) plateAvg30Map.set(row.plate, Number(row.avg_30d)); + if (row.avg_7d !== null) plateAvg7Map.set(row.plate, Number(row.avg_7d)); } - // Aggregate per customer: average of all plates belonging to each customer - const customerPlates = new Map(); + const customerPlates30 = new Map(); + const customerPlates7 = new Map(); for (const plate of allPlates) { const info = vehicleInfoMap.get(plate); const customer = info?.customer || '未知客户'; - if (!customerPlates.has(customer)) customerPlates.set(customer, []); - const avg = plateAvgMap.get(plate); - if (avg !== undefined) customerPlates.get(customer)!.push(avg); + if (!customerPlates30.has(customer)) customerPlates30.set(customer, []); + if (!customerPlates7.has(customer)) customerPlates7.set(customer, []); + const v30 = plateAvg30Map.get(plate); + const v7 = plateAvg7Map.get(plate); + if (v30 !== undefined) customerPlates30.get(customer)!.push(v30); + if (v7 !== undefined) customerPlates7.get(customer)!.push(v7); } - for (const [customer, avgs] of customerPlates) { - if (avgs.length > 0) { - customerAvgDailyMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length); - } + for (const [customer, avgs] of customerPlates30) { + if (avgs.length > 0) customerAvgDailyMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length); + } + for (const [customer, avgs] of customerPlates7) { + if (avgs.length > 0) customerAvgDaily7dMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length); } } @@ -208,6 +217,7 @@ app.get('/', async (c) => { const customer = info?.customer || null; const customerAvgDaily = customerAvgDailyMap.get(customer || '未知客户') || 0; + const customerAvgDaily7d = customerAvgDaily7dMap.get(customer || '未知客户') || 0; const currentYearMileage = Number(row.current_year_mileage) || 0; const yearTarget = Number(row.current_year_mileage_task) || 0; const predictedYearEnd = currentYearMileage + customerAvgDaily * daysLeft; @@ -233,6 +243,7 @@ app.get('/', async (c) => { department: info?.department || null, manager: info?.manager || null, customerAvgDaily, + customerAvgDaily7d, predictedYearEnd, daysLeft, classification, diff --git a/src/server/routes/scheduling/types.ts b/src/server/routes/scheduling/types.ts index 9e5bdae..2dd5b79 100644 --- a/src/server/routes/scheduling/types.ts +++ b/src/server/routes/scheduling/types.ts @@ -39,6 +39,7 @@ export interface EnrichedVehicle { department: string | null; manager: string | null; customerAvgDaily: number; + customerAvgDaily7d: number; predictedYearEnd: number; daysLeft: number; classification: VehicleClassification; diff --git a/src/shared/scheduling/types.ts b/src/shared/scheduling/types.ts index 9417aac..8003361 100644 --- a/src/shared/scheduling/types.ts +++ b/src/shared/scheduling/types.ts @@ -17,6 +17,7 @@ export interface SchedulingVehicleInfo { department: string | null; manager: string | null; customerAvgDaily: number; + customerAvgDaily7d: number; predictedYearEnd: number; daysLeft: number; }
暂无记录