316 lines
15 KiB
TypeScript
316 lines
15 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
import { Activity, RotateCcw, Search, ChevronDown } from 'lucide-react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import { fetchSuggestions, sendNotifyBatch } from './api';
|
|
import type { SchedulingResponse, SchedulingSuggestion } from './types';
|
|
import SuggestionList from './SuggestionList';
|
|
import SuggestionDetail from './SuggestionDetail';
|
|
import NotificationHistory from './NotificationHistory';
|
|
import { exportSuggestionsCsv } from './csv-export';
|
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
|
import { PageFrame } from '../../components/ui/surface';
|
|
import ActiveFilterTags from './scheduling-module/ActiveFilterTags';
|
|
import BatchActionBar from './scheduling-module/BatchActionBar';
|
|
import BatchConfirmModal from './scheduling-module/BatchConfirmModal';
|
|
import ListHeader from './scheduling-module/ListHeader';
|
|
import ListLoadingSkeleton from './scheduling-module/ListLoadingSkeleton';
|
|
import SchedulingSkeleton from './scheduling-module/SchedulingSkeleton';
|
|
import SummaryCards from './scheduling-module/SummaryCards';
|
|
import {
|
|
EMPTY_FILTERS,
|
|
buildBatchItems,
|
|
buildFilterOptions,
|
|
countActiveFilters,
|
|
filterSuggestions,
|
|
hasResettableFilters,
|
|
type AdvancedFilters,
|
|
type TypeFilter,
|
|
} from './scheduling-module/model';
|
|
|
|
function FilterSelect({ label, options, value, onChange, placeholder }: {
|
|
label: string; options: string[]; value: string; onChange: (v: string) => void; placeholder: string;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const filtered = options.filter(o => o.toLowerCase().includes(search.toLowerCase()));
|
|
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, []);
|
|
|
|
return (
|
|
<div ref={ref} className="space-y-1">
|
|
<label className="text-[10px] text-slate-400 uppercase font-bold">{label}</label>
|
|
<button
|
|
onClick={() => setOpen(!open)}
|
|
className="w-full flex items-center justify-between bg-slate-50 rounded-lg px-3 py-2 text-xs text-left cursor-pointer hover:bg-slate-100 transition-colors"
|
|
>
|
|
<span className={value ? 'text-slate-800 font-medium' : 'text-slate-400'}>{value || placeholder}</span>
|
|
<ChevronDown size={14} className={`text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
{open && (
|
|
<div className="bg-white border border-slate-200 rounded-lg shadow-lg max-h-48 overflow-hidden z-10 relative">
|
|
{options.length > 5 && (
|
|
<div className="p-1.5 border-b border-slate-100">
|
|
<div className="relative">
|
|
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" />
|
|
<input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索..." autoFocus
|
|
className="w-full pl-7 pr-2 py-1.5 text-xs bg-slate-50 rounded border-none outline-none" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="overflow-y-auto max-h-36">
|
|
<button onClick={() => { onChange(''); setOpen(false); setSearch(''); }}
|
|
className={`w-full text-left px-3 py-2 text-xs hover:bg-slate-50 cursor-pointer ${!value ? 'text-blue-600 font-bold' : 'text-slate-400'}`}>全部</button>
|
|
{filtered.map(opt => (
|
|
<button key={opt} onClick={() => { onChange(opt); setOpen(false); setSearch(''); }}
|
|
className={`w-full text-left px-3 py-2 text-xs hover:bg-slate-50 cursor-pointer ${value === opt ? 'text-blue-600 font-bold bg-blue-50' : 'text-slate-700'}`}>{opt}</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function SchedulingModule() {
|
|
const [data, setData] = useState<SchedulingResponse | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [selectedTargetId, setSelectedTargetId] = useState<number | undefined>(undefined);
|
|
const [selectedSuggestion, setSelectedSuggestion] = useState<SchedulingSuggestion | null>(null);
|
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
|
const [showFilter, setShowFilter] = useState(false);
|
|
const [filters, setFilters] = useState<AdvancedFilters>(EMPTY_FILTERS);
|
|
const [tempFilters, setTempFilters] = useState<AdvancedFilters>(EMPTY_FILTERS);
|
|
const [selectMode, setSelectMode] = useState(false);
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
const [showBatchConfirm, setShowBatchConfirm] = useState(false);
|
|
const [batchInFlight, setBatchInFlight] = useState(false);
|
|
const [batchResultMsg, setBatchResultMsg] = useState<string | null>(null);
|
|
const [showHistory, setShowHistory] = useState(false);
|
|
const [historyRecentOnly, setHistoryRecentOnly] = useState(false);
|
|
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true);
|
|
try { setData(await fetchSuggestions(selectedTargetId)); } finally { setLoading(false); }
|
|
}, [selectedTargetId]);
|
|
|
|
useEffect(() => { loadData(); }, [loadData]);
|
|
const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]);
|
|
|
|
// Keep selectedSuggestion synced with latest data so candidate notification
|
|
// status changes (登记 / 取消干预) propagate into the open detail modal.
|
|
useEffect(() => {
|
|
if (!selectedSuggestion || !data) return;
|
|
const fresh = data.suggestions.find(s => s.id === selectedSuggestion.id);
|
|
if (!fresh) setSelectedSuggestion(null);
|
|
else if (fresh !== selectedSuggestion) setSelectedSuggestion(fresh);
|
|
}, [data, selectedSuggestion]);
|
|
|
|
const toggleSelect = useCallback((id: string) => {
|
|
setSelectedIds(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id); else next.add(id);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const exitSelectMode = useCallback(() => {
|
|
setSelectMode(false);
|
|
setSelectedIds(new Set());
|
|
setShowBatchConfirm(false);
|
|
}, []);
|
|
|
|
const batchItems = useMemo(() => {
|
|
return buildBatchItems(data, selectedIds);
|
|
}, [data, selectedIds]);
|
|
|
|
const handleBatchSubmit = useCallback(async () => {
|
|
if (batchItems.length === 0) return;
|
|
setBatchInFlight(true);
|
|
try {
|
|
const resp = await sendNotifyBatch({
|
|
items: batchItems.map(i => ({
|
|
suggestionId: i.suggestion.id,
|
|
currentPlate: i.suggestion.currentVehicle.plateNumber,
|
|
candidatePlate: i.candidate.plateNumber,
|
|
})),
|
|
});
|
|
setBatchResultMsg(resp.message);
|
|
await loadData();
|
|
exitSelectMode();
|
|
} catch (e) {
|
|
console.error('batch notify failed:', e);
|
|
setBatchResultMsg('批量干预失败,请重试');
|
|
} finally {
|
|
setBatchInFlight(false);
|
|
}
|
|
}, [batchItems, loadData, exitSelectMode]);
|
|
|
|
const filterOptions = useMemo(() => {
|
|
return buildFilterOptions(data);
|
|
}, [data]);
|
|
|
|
const filteredSuggestions = useMemo(() => {
|
|
return filterSuggestions(data, typeFilter, filters);
|
|
}, [data, typeFilter, filters]);
|
|
|
|
const summary = data?.summary;
|
|
const activeFilterCount = countActiveFilters(filters);
|
|
|
|
// Initial load — full page skeleton
|
|
if (loading && !data) return <SchedulingSkeleton />;
|
|
|
|
return (
|
|
<PageFrame
|
|
title="智能调度工作台"
|
|
subtitle="自动识别高里程可释放车辆与低里程待救援车辆,形成可登记、可追踪的运营干预建议。"
|
|
icon={Activity}
|
|
eyebrow="SCHEDULING OPS"
|
|
meta={`当前 ${filteredSuggestions.length} 条建议 · ${activeFilterCount} 个筛选条件`}
|
|
actions={(
|
|
<button onClick={loadData} disabled={loading} className="inline-flex items-center gap-1.5 rounded-xl bg-slate-900 px-3 py-2 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800 disabled:opacity-60">
|
|
<RotateCcw size={14} className={loading ? 'animate-spin' : ''} />
|
|
刷新建议
|
|
</button>
|
|
)}
|
|
>
|
|
|
|
{/* ===== Summary Cards ===== */}
|
|
<SummaryCards
|
|
summary={summary}
|
|
typeFilter={typeFilter}
|
|
onTypeFilterChange={setTypeFilter}
|
|
onShowRecentHistory={() => { setShowHistory(true); setHistoryRecentOnly(true); }}
|
|
/>
|
|
|
|
{/* ===== List Card ===== */}
|
|
<div className="bg-white rounded-2xl border border-slate-200/60 shadow-sm overflow-hidden">
|
|
|
|
{/* Header */}
|
|
<ListHeader
|
|
loading={loading}
|
|
targets={data?.targets}
|
|
selectedTargetId={selectedTargetId}
|
|
selectMode={selectMode}
|
|
showFilter={showFilter}
|
|
activeFilterCount={activeFilterCount}
|
|
canExport={filteredSuggestions.length > 0}
|
|
onRefresh={loadData}
|
|
onExport={() => exportSuggestionsCsv(filteredSuggestions)}
|
|
onShowHistory={() => { setShowHistory(true); setHistoryRecentOnly(false); }}
|
|
onToggleSelectMode={() => {
|
|
if (selectMode) exitSelectMode();
|
|
else { setSelectMode(true); setSelectedSuggestion(null); }
|
|
}}
|
|
onToggleFilter={() => { setShowFilter(!showFilter); setTempFilters(filters); }}
|
|
onSelectAll={() => { setSelectedTargetId(undefined); setTypeFilter('all'); }}
|
|
onSelectTarget={targetId => { setSelectedTargetId(targetId); setTypeFilter('all'); }}
|
|
/>
|
|
|
|
{/* Filter Panel */}
|
|
<AnimatePresence>
|
|
{showFilter && (
|
|
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="overflow-hidden border-b border-slate-100">
|
|
<div className="px-4 py-4 bg-slate-50/60 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-bold text-slate-700">高级筛选</span>
|
|
{hasResettableFilters(tempFilters) && (
|
|
<button onClick={() => setTempFilters(EMPTY_FILTERS)} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer">重置</button>
|
|
)}
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] text-slate-400 uppercase font-bold">车牌号</label>
|
|
<div className="relative">
|
|
<Search size={12} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
|
<input type="text" value={tempFilters.plateSearch} onChange={e => setTempFilters(prev => ({ ...prev, plateSearch: e.target.value }))}
|
|
placeholder="搜索车牌号..." className="w-full pl-8 pr-3 py-2 bg-white rounded-lg text-xs border border-slate-200 outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<FilterSelect label="运营区域" options={filterOptions.regions} value={tempFilters.region} onChange={v => setTempFilters(prev => ({ ...prev, region: v }))} placeholder="全部区域" />
|
|
<FilterSelect label="车辆类型" options={filterOptions.vehicleTypes} value={tempFilters.vehicleType} onChange={v => setTempFilters(prev => ({ ...prev, vehicleType: v }))} placeholder="全部类型" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<FilterSelect label="业务部门" options={filterOptions.departments} value={tempFilters.department} onChange={v => setTempFilters(prev => ({ ...prev, department: v }))} placeholder="全部部门" />
|
|
<FilterSelect label="业务负责人" options={filterOptions.managers} value={tempFilters.manager} onChange={v => setTempFilters(prev => ({ ...prev, manager: v }))} placeholder="全部负责人" />
|
|
</div>
|
|
<FilterSelect label="客户" options={filterOptions.customers} value={tempFilters.customer} onChange={v => setTempFilters(prev => ({ ...prev, customer: v }))} placeholder="全部客户" />
|
|
<div className="flex gap-2 pt-1">
|
|
<button onClick={() => setShowFilter(false)} className="flex-1 py-2 text-xs font-bold text-slate-500 bg-white border border-slate-200 rounded-lg cursor-pointer hover:bg-slate-50 transition-colors">取消</button>
|
|
<button onClick={() => { setFilters(tempFilters); setShowFilter(false); }} className="flex-1 py-2 text-xs font-bold text-white bg-slate-800 rounded-lg cursor-pointer hover:bg-slate-900 transition-colors shadow-sm">确认筛选</button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Active filter tags */}
|
|
{activeFilterCount > 0 && !showFilter && (
|
|
<ActiveFilterTags
|
|
filters={filters}
|
|
onChange={setFilters}
|
|
onClear={() => setFilters(EMPTY_FILTERS)}
|
|
/>
|
|
)}
|
|
|
|
{(activeFilterCount > 0 || typeFilter !== 'all') && (
|
|
<div className="px-4 py-1.5 border-b border-slate-50 text-[10px] text-slate-400">共 {filteredSuggestions.length} 条结果</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
/* List skeleton while refreshing */
|
|
<ListLoadingSkeleton />
|
|
) : (
|
|
<SuggestionList
|
|
suggestions={filteredSuggestions}
|
|
onSelect={setSelectedSuggestion}
|
|
selectMode={selectMode}
|
|
selectedIds={selectedIds}
|
|
onToggleSelect={toggleSelect}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{selectedSuggestion && (
|
|
<SuggestionDetail suggestion={selectedSuggestion} onClose={() => setSelectedSuggestion(null)} onNotifySuccess={handleNotifySuccess} />
|
|
)}
|
|
|
|
{showHistory && (
|
|
<NotificationHistory
|
|
onClose={() => setShowHistory(false)}
|
|
onChange={loadData}
|
|
recentOnly={historyRecentOnly}
|
|
suggestions={data?.suggestions}
|
|
/>
|
|
)}
|
|
|
|
{/* Batch action bar */}
|
|
<AnimatePresence>
|
|
{selectMode && (
|
|
<BatchActionBar
|
|
selectedCount={selectedIds.size}
|
|
onCancel={exitSelectMode}
|
|
onConfirm={() => setShowBatchConfirm(true)}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Batch confirmation modal */}
|
|
{showBatchConfirm && (
|
|
<BatchConfirmModal
|
|
batchItems={batchItems}
|
|
batchInFlight={batchInFlight}
|
|
batchResultMsg={batchResultMsg}
|
|
onClose={() => setShowBatchConfirm(false)}
|
|
onSubmit={handleBatchSubmit}
|
|
/>
|
|
)}
|
|
<RotatingFooterHint className="pb-4" />
|
|
</PageFrame>
|
|
);
|
|
}
|