feat(scheduling): add department/manager filters, refine color palette
- Add department and manager fields to backend types and suggestions API - Add department/manager to advanced filter panel - Refine card colors: orange (换下) / blue (换走) / dark slate (全部) - Selected card uses solid bg color, inactive uses gradient - Batch pills use dark slate, confirm button uses dark slate - Background changed to #F0F4F8 for subtle cool tone Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
48fa3bc73f
commit
9398688829
@@ -13,9 +13,11 @@ interface AdvancedFilters {
|
|||||||
region: string;
|
region: string;
|
||||||
vehicleType: string;
|
vehicleType: string;
|
||||||
customer: string;
|
customer: string;
|
||||||
|
department: string;
|
||||||
|
manager: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_FILTERS: AdvancedFilters = { plateSearch: '', region: '', vehicleType: '', customer: '' };
|
const EMPTY_FILTERS: AdvancedFilters = { plateSearch: '', region: '', vehicleType: '', customer: '', department: '', manager: '' };
|
||||||
|
|
||||||
function shortTargetName(name: string): string {
|
function shortTargetName(name: string): string {
|
||||||
const match = name.match(/(\d+)[辆台](.+)/);
|
const match = name.match(/(\d+)[辆台](.+)/);
|
||||||
@@ -32,24 +34,16 @@ function hasActiveFilters(f: AdvancedFilters): boolean {
|
|||||||
return f.plateSearch !== '' || f.region !== '' || f.vehicleType !== '' || f.customer !== '';
|
return f.plateSearch !== '' || f.region !== '' || f.vehicleType !== '' || f.customer !== '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dropdown select with search */
|
|
||||||
function FilterSelect({ label, options, value, onChange, placeholder }: {
|
function FilterSelect({ label, options, value, onChange, placeholder }: {
|
||||||
label: string;
|
label: string; options: string[]; value: string; onChange: (v: string) => void; placeholder: string;
|
||||||
options: string[];
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
placeholder: string;
|
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const filtered = options.filter(o => o.toLowerCase().includes(search.toLowerCase()));
|
const filtered = options.filter(o => o.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handler);
|
document.addEventListener('mousedown', handler);
|
||||||
return () => document.removeEventListener('mousedown', handler);
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -65,37 +59,22 @@ function FilterSelect({ label, options, value, onChange, placeholder }: {
|
|||||||
<ChevronDown size={14} className={`text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`} />
|
<ChevronDown size={14} className={`text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="bg-white border border-slate-200 rounded-lg shadow-lg max-h-48 overflow-hidden z-10">
|
<div className="bg-white border border-slate-200 rounded-lg shadow-lg max-h-48 overflow-hidden z-10 relative">
|
||||||
{options.length > 5 && (
|
{options.length > 5 && (
|
||||||
<div className="p-1.5 border-b border-slate-100">
|
<div className="p-1.5 border-b border-slate-100">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" />
|
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
<input
|
<input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索..." autoFocus
|
||||||
type="text"
|
className="w-full pl-7 pr-2 py-1.5 text-xs bg-slate-50 rounded border-none outline-none" />
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
placeholder="搜索..."
|
|
||||||
className="w-full pl-7 pr-2 py-1.5 text-xs bg-slate-50 rounded border-none outline-none"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="overflow-y-auto max-h-36">
|
<div className="overflow-y-auto max-h-36">
|
||||||
<button
|
<button onClick={() => { onChange(''); setOpen(false); setSearch(''); }}
|
||||||
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>
|
||||||
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 => (
|
{filtered.map(opt => (
|
||||||
<button
|
<button key={opt} onClick={() => { onChange(opt); setOpen(false); setSearch(''); }}
|
||||||
key={opt}
|
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>
|
||||||
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>
|
</div>
|
||||||
@@ -116,169 +95,153 @@ export default function SchedulingModule() {
|
|||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try { setData(await fetchSuggestions(selectedTargetId)); } finally { setLoading(false); }
|
||||||
const result = await fetchSuggestions(selectedTargetId);
|
|
||||||
setData(result);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [selectedTargetId]);
|
}, [selectedTargetId]);
|
||||||
|
|
||||||
useEffect(() => { loadData(); }, [loadData]);
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]);
|
const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
// Compute filter options from data
|
|
||||||
const filterOptions = useMemo(() => {
|
const filterOptions = useMemo(() => {
|
||||||
if (!data) return { regions: [], vehicleTypes: [], customers: [] };
|
if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] };
|
||||||
const regions = new Set<string>();
|
const r = new Set<string>(), t = new Set<string>(), c = new Set<string>(), d = new Set<string>(), m = new Set<string>();
|
||||||
const vehicleTypes = new Set<string>();
|
|
||||||
const customers = new Set<string>();
|
|
||||||
for (const s of data.suggestions) {
|
for (const s of data.suggestions) {
|
||||||
const v = s.currentVehicle;
|
const v = s.currentVehicle;
|
||||||
if (v.region) regions.add(v.region);
|
if (v.region) r.add(v.region);
|
||||||
if (v.vehicleType) vehicleTypes.add(v.vehicleType);
|
if (v.vehicleType) t.add(v.vehicleType);
|
||||||
if (v.customer) customers.add(v.customer);
|
if (v.customer) c.add(v.customer);
|
||||||
|
if (v.department) d.add(v.department);
|
||||||
|
if (v.manager) m.add(v.manager);
|
||||||
}
|
}
|
||||||
return {
|
return { regions: [...r].sort(), vehicleTypes: [...t].sort(), customers: [...c].sort(), departments: [...d].sort(), managers: [...m].sort() };
|
||||||
regions: Array.from(regions).sort(),
|
|
||||||
vehicleTypes: Array.from(vehicleTypes).sort(),
|
|
||||||
customers: Array.from(customers).sort(),
|
|
||||||
};
|
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const filteredSuggestions = useMemo(() => {
|
const filteredSuggestions = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
let list = data.suggestions;
|
let list = data.suggestions;
|
||||||
|
|
||||||
// Type filter from cards
|
|
||||||
if (typeFilter === 'qualified') list = list.filter(s => s.type === 'replace_qualified');
|
if (typeFilter === 'qualified') list = list.filter(s => s.type === 'replace_qualified');
|
||||||
if (typeFilter === 'hopeless') list = list.filter(s => s.type === 'rescue_hopeless');
|
if (typeFilter === 'hopeless') list = list.filter(s => s.type === 'rescue_hopeless');
|
||||||
|
if (filters.plateSearch) { const q = filters.plateSearch.toLowerCase(); list = list.filter(s => s.currentVehicle.plateNumber.toLowerCase().includes(q)); }
|
||||||
// Advanced filters
|
|
||||||
if (filters.plateSearch) {
|
|
||||||
const q = filters.plateSearch.toLowerCase();
|
|
||||||
list = list.filter(s => s.currentVehicle.plateNumber.toLowerCase().includes(q));
|
|
||||||
}
|
|
||||||
if (filters.region) list = list.filter(s => s.currentVehicle.region === filters.region);
|
if (filters.region) list = list.filter(s => s.currentVehicle.region === filters.region);
|
||||||
if (filters.vehicleType) list = list.filter(s => s.currentVehicle.vehicleType === filters.vehicleType);
|
if (filters.vehicleType) list = list.filter(s => s.currentVehicle.vehicleType === filters.vehicleType);
|
||||||
if (filters.customer) list = list.filter(s => s.currentVehicle.customer === filters.customer);
|
if (filters.customer) list = list.filter(s => s.currentVehicle.customer === filters.customer);
|
||||||
|
if (filters.department) list = list.filter(s => s.currentVehicle.department === filters.department);
|
||||||
|
if (filters.manager) list = list.filter(s => s.currentVehicle.manager === filters.manager);
|
||||||
return list;
|
return list;
|
||||||
}, [data, typeFilter, filters]);
|
}, [data, typeFilter, filters]);
|
||||||
|
|
||||||
const summary = data?.summary;
|
const summary = data?.summary;
|
||||||
const activeFilterCount = [filters.plateSearch, filters.region, filters.vehicleType, filters.customer].filter(Boolean).length;
|
const activeFilterCount = [filters.plateSearch, filters.region, filters.vehicleType, filters.customer, filters.department, filters.manager].filter(Boolean).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6" style={{ overflowX: 'clip' }}>
|
<div className="min-h-screen bg-[#F0F4F8] text-slate-800 font-sans p-3 md:p-6" style={{ overflowX: 'clip' }}>
|
||||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
|
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
|
||||||
|
|
||||||
{/* ========== Top: Summary Cards ========== */}
|
{/* ===== Summary Cards ===== */}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-2.5">
|
||||||
|
{/* 里程高·换下 — warm orange */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')}
|
onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')}
|
||||||
className={`p-4 rounded-2xl text-left transition-all cursor-pointer ${
|
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
|
||||||
typeFilter === 'qualified'
|
typeFilter === 'qualified'
|
||||||
? 'bg-amber-100 border-2 border-amber-400 shadow-sm'
|
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25'
|
||||||
: 'bg-amber-50 border border-amber-100'
|
: 'bg-gradient-to-br from-orange-50 to-amber-50 border border-orange-200/60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="text-[10px] font-bold text-amber-700 mb-1">里程高·需换下</div>
|
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'qualified' ? 'text-orange-100' : 'text-orange-600'}`}>
|
||||||
<div className="text-2xl font-black text-amber-800">
|
里程高·需换下
|
||||||
|
</div>
|
||||||
|
<div className={`text-2xl font-black ${typeFilter === 'qualified' ? 'text-white' : 'text-orange-700'}`}>
|
||||||
{loading && !data ? '-' : summary?.qualifiedCount ?? 0}
|
{loading && !data ? '-' : summary?.qualifiedCount ?? 0}
|
||||||
<span className="text-xs font-normal text-amber-600 ml-1">台</span>
|
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}>台</span>
|
||||||
|
</div>
|
||||||
|
<div className={`text-[9px] mt-0.5 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}>
|
||||||
|
已达标,换上里程少的车
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[9px] text-amber-600 mt-1">已达标,换上里程少的车</div>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 里程低·换走 — cool blue */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')}
|
onClick={() => setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')}
|
||||||
className={`p-4 rounded-2xl text-left transition-all cursor-pointer ${
|
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
|
||||||
typeFilter === 'hopeless'
|
typeFilter === 'hopeless'
|
||||||
? 'bg-blue-100 border-2 border-blue-400 shadow-sm'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/25'
|
||||||
: 'bg-blue-50 border border-blue-100'
|
: 'bg-gradient-to-br from-blue-50 to-indigo-50 border border-blue-200/60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="text-[10px] font-bold text-blue-700 mb-1">里程低·需换走</div>
|
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'hopeless' ? 'text-blue-100' : 'text-blue-600'}`}>
|
||||||
<div className="text-2xl font-black text-blue-800">
|
里程低·需换走
|
||||||
|
</div>
|
||||||
|
<div className={`text-2xl font-black ${typeFilter === 'hopeless' ? 'text-white' : 'text-blue-700'}`}>
|
||||||
{loading && !data ? '-' : summary?.hopelessCount ?? 0}
|
{loading && !data ? '-' : summary?.hopelessCount ?? 0}
|
||||||
<span className="text-xs font-normal text-blue-600 ml-1">台</span>
|
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}>台</span>
|
||||||
|
</div>
|
||||||
|
<div className={`text-[9px] mt-0.5 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}>
|
||||||
|
无法达标,调给高里程客户
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[9px] text-blue-600 mt-1">无法达标,调给高里程客户</div>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 替换建议 — neutral dark */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setTypeFilter('all')}
|
onClick={() => setTypeFilter('all')}
|
||||||
className={`p-4 rounded-2xl text-left transition-all cursor-pointer ${
|
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
|
||||||
typeFilter === 'all'
|
typeFilter === 'all'
|
||||||
? 'bg-emerald-100 border-2 border-emerald-400 shadow-sm'
|
? 'bg-slate-800 text-white shadow-lg shadow-slate-800/25'
|
||||||
: 'bg-emerald-50 border border-emerald-100'
|
: 'bg-gradient-to-br from-slate-50 to-slate-100 border border-slate-200/60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="text-[10px] font-bold text-emerald-700 mb-1">替换建议</div>
|
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'all' ? 'text-slate-300' : 'text-slate-500'}`}>
|
||||||
<div className="text-2xl font-black text-emerald-800">
|
替换建议
|
||||||
{loading && !data ? '-' : summary?.suggestionCount ?? 0}
|
|
||||||
<span className="text-xs font-normal text-emerald-600 ml-1">条</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[9px] text-emerald-600 mt-1">
|
<div className={`text-2xl font-black ${typeFilter === 'all' ? 'text-white' : 'text-slate-800'}`}>
|
||||||
|
{loading && !data ? '-' : summary?.suggestionCount ?? 0}
|
||||||
|
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}>条</span>
|
||||||
|
</div>
|
||||||
|
<div className={`text-[9px] mt-0.5 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}>
|
||||||
预计 +{summary?.estimatedGain ?? 0} 台可新增达标
|
预计 +{summary?.estimatedGain ?? 0} 台可新增达标
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ========== Bottom: List Card ========== */}
|
{/* ===== List Card ===== */}
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-2xl border border-slate-200/60 shadow-sm overflow-hidden">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-4 py-3 border-b border-slate-100">
|
<div className="px-4 py-3 border-b border-slate-100">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-bold text-slate-900">智能调度干预清单</h3>
|
<h3 className="text-sm font-bold text-slate-900">智能调度干预清单</h3>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button onClick={loadData} disabled={loading}
|
||||||
onClick={loadData}
|
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer">
|
||||||
disabled={loading}
|
|
||||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer"
|
|
||||||
>
|
|
||||||
<RotateCcw size={15} className={loading ? 'animate-spin' : ''} />
|
<RotateCcw size={15} className={loading ? 'animate-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowFilter(!showFilter); setTempFilters(filters); }}
|
onClick={() => { setShowFilter(!showFilter); setTempFilters(filters); }}
|
||||||
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
|
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
|
||||||
showFilter || activeFilterCount > 0
|
showFilter || activeFilterCount > 0 ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
|
||||||
? 'text-blue-600 bg-blue-50'
|
|
||||||
: 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Filter size={15} />
|
<Filter size={15} />
|
||||||
{activeFilterCount > 0 && (
|
{activeFilterCount > 0 && (
|
||||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-600 text-white text-[8px] font-bold rounded-full flex items-center justify-center">
|
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-600 text-white text-[8px] font-bold rounded-full flex items-center justify-center">{activeFilterCount}</span>
|
||||||
{activeFilterCount}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Batch selector pills */}
|
|
||||||
<div className="flex gap-2 overflow-x-auto no-scrollbar">
|
<div className="flex gap-2 overflow-x-auto no-scrollbar">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSelectedTargetId(undefined); setTypeFilter('all'); }}
|
onClick={() => { setSelectedTargetId(undefined); setTypeFilter('all'); }}
|
||||||
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
|
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
|
||||||
selectedTargetId === undefined
|
selectedTargetId === undefined ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||||
? 'bg-blue-600 text-white shadow-md shadow-blue-600/20'
|
|
||||||
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
全部批次
|
全部批次
|
||||||
</button>
|
</button>
|
||||||
{data?.targets.map(t => (
|
{data?.targets.map(t => (
|
||||||
<button
|
<button key={t.id}
|
||||||
key={t.id}
|
|
||||||
onClick={() => { setSelectedTargetId(t.id); setTypeFilter('all'); }}
|
onClick={() => { setSelectedTargetId(t.id); setTypeFilter('all'); }}
|
||||||
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
|
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
|
||||||
selectedTargetId === t.id
|
selectedTargetId === t.id ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||||
? 'bg-blue-600 text-white shadow-md shadow-blue-600/20'
|
|
||||||
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{shortTargetName(t.name)}
|
{shortTargetName(t.name)}
|
||||||
@@ -287,83 +250,37 @@ export default function SchedulingModule() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Advanced Filter Panel */}
|
{/* Filter Panel */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showFilter && (
|
{showFilter && (
|
||||||
<motion.div
|
<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">
|
||||||
initial={{ height: 0, opacity: 0 }}
|
<div className="px-4 py-4 bg-slate-50/60 space-y-3">
|
||||||
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/50 space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-bold text-slate-700">高级筛选</span>
|
<span className="text-xs font-bold text-slate-700">高级筛选</span>
|
||||||
{hasActiveFilters(tempFilters) && (
|
{hasActiveFilters(tempFilters) && (
|
||||||
<button
|
<button onClick={() => setTempFilters(EMPTY_FILTERS)} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer">重置</button>
|
||||||
onClick={() => setTempFilters(EMPTY_FILTERS)}
|
|
||||||
className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer"
|
|
||||||
>
|
|
||||||
重置
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Plate search */}
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] text-slate-400 uppercase font-bold">车牌号</label>
|
<label className="text-[10px] text-slate-400 uppercase font-bold">车牌号</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search size={12} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
<Search size={12} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
<input
|
<input type="text" value={tempFilters.plateSearch} onChange={e => setTempFilters(prev => ({ ...prev, plateSearch: e.target.value }))}
|
||||||
type="text"
|
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" />
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Selects in 2-column grid */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<FilterSelect
|
<FilterSelect label="运营区域" options={filterOptions.regions} value={tempFilters.region} onChange={v => setTempFilters(prev => ({ ...prev, region: v }))} placeholder="全部区域" />
|
||||||
label="运营区域"
|
<FilterSelect label="车辆类型" options={filterOptions.vehicleTypes} value={tempFilters.vehicleType} onChange={v => setTempFilters(prev => ({ ...prev, vehicleType: v }))} placeholder="全部类型" />
|
||||||
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>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<FilterSelect
|
<FilterSelect label="业务部门" options={filterOptions.departments} value={tempFilters.department} onChange={v => setTempFilters(prev => ({ ...prev, department: v }))} placeholder="全部部门" />
|
||||||
label="客户"
|
<FilterSelect label="业务负责人" options={filterOptions.managers} value={tempFilters.manager} onChange={v => setTempFilters(prev => ({ ...prev, manager: v }))} placeholder="全部负责人" />
|
||||||
options={filterOptions.customers}
|
</div>
|
||||||
value={tempFilters.customer}
|
<FilterSelect label="客户" options={filterOptions.customers} value={tempFilters.customer} onChange={v => setTempFilters(prev => ({ ...prev, customer: v }))} placeholder="全部客户" />
|
||||||
onChange={v => setTempFilters(prev => ({ ...prev, customer: v }))}
|
|
||||||
placeholder="全部客户"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Apply / Cancel */}
|
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<button
|
<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>
|
||||||
onClick={() => setShowFilter(false)}
|
<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>
|
||||||
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-blue-600 rounded-lg cursor-pointer hover:bg-blue-700 transition-colors shadow-sm shadow-blue-200"
|
|
||||||
>
|
|
||||||
确认筛选
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -374,66 +291,31 @@ export default function SchedulingModule() {
|
|||||||
{activeFilterCount > 0 && !showFilter && (
|
{activeFilterCount > 0 && !showFilter && (
|
||||||
<div className="px-4 py-2 border-b border-slate-100 flex items-center gap-2 flex-wrap">
|
<div className="px-4 py-2 border-b border-slate-100 flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-[10px] text-slate-400">筛选:</span>
|
<span className="text-[10px] text-slate-400">筛选:</span>
|
||||||
{filters.plateSearch && (
|
{filters.plateSearch && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">车牌 "{filters.plateSearch}" <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, plateSearch: '' }))} /></span>}
|
||||||
<span className="text-[10px] bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full flex items-center gap-1">
|
{filters.region && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.region} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, region: '' }))} /></span>}
|
||||||
车牌 "{filters.plateSearch}"
|
{filters.vehicleType && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.vehicleType} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, vehicleType: '' }))} /></span>}
|
||||||
<X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, plateSearch: '' }))} />
|
{filters.department && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.department} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, department: '' }))} /></span>}
|
||||||
</span>
|
{filters.manager && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.manager} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, manager: '' }))} /></span>}
|
||||||
)}
|
{filters.customer && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.customer} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, customer: '' }))} /></span>}
|
||||||
{filters.region && (
|
<button onClick={() => setFilters(EMPTY_FILTERS)} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer">清除</button>
|
||||||
<span className="text-[10px] bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full flex items-center gap-1">
|
|
||||||
{filters.region}
|
|
||||||
<X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, region: '' }))} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{filters.vehicleType && (
|
|
||||||
<span className="text-[10px] bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full flex items-center gap-1">
|
|
||||||
{filters.vehicleType}
|
|
||||||
<X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, vehicleType: '' }))} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{filters.customer && (
|
|
||||||
<span className="text-[10px] bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full flex items-center gap-1">
|
|
||||||
{filters.customer}
|
|
||||||
<X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, customer: '' }))} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setFilters(EMPTY_FILTERS)}
|
|
||||||
className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer"
|
|
||||||
>
|
|
||||||
清除全部
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Result count when filtered */}
|
|
||||||
{(activeFilterCount > 0 || typeFilter !== 'all') && (
|
{(activeFilterCount > 0 || typeFilter !== 'all') && (
|
||||||
<div className="px-4 py-1.5 border-b border-slate-50 text-[10px] text-slate-400">
|
<div className="px-4 py-1.5 border-b border-slate-50 text-[10px] text-slate-400">共 {filteredSuggestions.length} 条结果</div>
|
||||||
共 {filteredSuggestions.length} 条结果
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* List body */}
|
|
||||||
{loading && !data ? (
|
{loading && !data ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SuggestionList
|
<SuggestionList suggestions={filteredSuggestions} onSelect={setSelectedSuggestion} />
|
||||||
suggestions={filteredSuggestions}
|
|
||||||
onSelect={setSelectedSuggestion}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail Modal */}
|
|
||||||
{selectedSuggestion && (
|
{selectedSuggestion && (
|
||||||
<SuggestionDetail
|
<SuggestionDetail suggestion={selectedSuggestion} onClose={() => setSelectedSuggestion(null)} onNotifySuccess={handleNotifySuccess} />
|
||||||
suggestion={selectedSuggestion}
|
|
||||||
onClose={() => setSelectedSuggestion(null)}
|
|
||||||
onNotifySuccess={handleNotifySuccess}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface SchedulingVehicleInfo {
|
|||||||
region: string;
|
region: string;
|
||||||
province: string;
|
province: string;
|
||||||
customer: string | null;
|
customer: string | null;
|
||||||
|
department: string | null;
|
||||||
|
manager: string | null;
|
||||||
customerAvgDaily: number;
|
customerAvgDaily: number;
|
||||||
predictedYearEnd: number;
|
predictedYearEnd: number;
|
||||||
daysLeft: number;
|
daysLeft: number;
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export function toVehicleInfo(v: EnrichedVehicle): SchedulingVehicleInfo {
|
|||||||
region: v.region,
|
region: v.region,
|
||||||
province: v.province,
|
province: v.province,
|
||||||
customer: v.customer,
|
customer: v.customer,
|
||||||
|
department: v.department,
|
||||||
|
manager: v.manager,
|
||||||
customerAvgDaily: v.customerAvgDaily,
|
customerAvgDaily: v.customerAvgDaily,
|
||||||
predictedYearEnd: v.predictedYearEnd,
|
predictedYearEnd: v.predictedYearEnd,
|
||||||
daysLeft: v.daysLeft,
|
daysLeft: v.daysLeft,
|
||||||
|
|||||||
@@ -229,6 +229,8 @@ app.get('/', async (c) => {
|
|||||||
region,
|
region,
|
||||||
province,
|
province,
|
||||||
customer,
|
customer,
|
||||||
|
department: info?.department || null,
|
||||||
|
manager: info?.manager || null,
|
||||||
customerAvgDaily,
|
customerAvgDaily,
|
||||||
predictedYearEnd,
|
predictedYearEnd,
|
||||||
daysLeft,
|
daysLeft,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface SchedulingVehicleInfo {
|
|||||||
region: string;
|
region: string;
|
||||||
province: string;
|
province: string;
|
||||||
customer: string | null;
|
customer: string | null;
|
||||||
|
department: string | null;
|
||||||
|
manager: string | null;
|
||||||
customerAvgDaily: number;
|
customerAvgDaily: number;
|
||||||
predictedYearEnd: number;
|
predictedYearEnd: number;
|
||||||
daysLeft: number;
|
daysLeft: number;
|
||||||
@@ -81,6 +83,8 @@ export interface EnrichedVehicle {
|
|||||||
region: string;
|
region: string;
|
||||||
province: string;
|
province: string;
|
||||||
customer: string | null;
|
customer: string | null;
|
||||||
|
department: string | null;
|
||||||
|
manager: string | null;
|
||||||
customerAvgDaily: number;
|
customerAvgDaily: number;
|
||||||
predictedYearEnd: number;
|
predictedYearEnd: number;
|
||||||
daysLeft: number;
|
daysLeft: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user