Files
ln-bi/src/modules/scheduling/SchedulingModule.tsx
T

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>
);
}