refactor: modularize application domains

This commit is contained in:
kkfluous
2026-08-13 12:03:34 +08:00
parent d610e4b841
commit 06fe75b4c7
142 changed files with 14645 additions and 8885 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,261 @@
import React, { useEffect, useRef, useState } from 'react';
import { ChevronDown, Filter, Loader2, Search } from 'lucide-react';
import { motion } from 'motion/react';
import type { SubjectOption } from '../api';
export type AssetsTab = 'overview' | 'department' | 'region' | 'customer';
export type AssetsTheme = 'soft' | 'minimal' | 'vibrant';
interface AssetsHeaderProps {
activeTab: AssetsTab;
setActiveTab: React.Dispatch<React.SetStateAction<AssetsTab>>;
theme: AssetsTheme;
setTheme: React.Dispatch<React.SetStateAction<AssetsTheme>>;
lastUpdate: string;
loading: boolean;
selectedSubject: string | null;
setSelectedSubject: React.Dispatch<React.SetStateAction<string | null>>;
subjects: SubjectOption[];
subjectDropdownOpen: boolean;
setSubjectDropdownOpen: React.Dispatch<React.SetStateAction<boolean>>;
subjectSearch: string;
setSubjectSearch: React.Dispatch<React.SetStateAction<string>>;
subjectDropdownRef: React.RefObject<HTMLDivElement | null>;
}
// --- Constants ---
const TABS = [
{ id: 'overview', label: '总览' },
{ id: 'department', label: '按部门' },
{ id: 'region', label: '按区域' },
{ id: 'customer', label: '按客户' },
];
function MarqueeBanner() {
const trackRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const [overflow, setOverflow] = useState(false);
useEffect(() => {
const check = () => {
if (!trackRef.current || !innerRef.current) return;
setOverflow(innerRef.current.scrollWidth > trackRef.current.clientWidth);
};
check();
const ro = new ResizeObserver(check);
ro.observe(trackRef.current!);
return () => ro.disconnect();
}, []);
const text = '车辆资产已于 2026 年 6 月 18 日完成“运营状态”与“业务关联”校验';
return (
<div className="relative -mx-6 mb-4 bg-green-50 border-y border-green-200">
<div ref={trackRef} className="overflow-hidden">
<div className={`flex w-max py-2 ${overflow ? 'animate-marquee' : 'w-full justify-center'}`}>
<span ref={innerRef} className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
{text}
</span>
{overflow && (
<span className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
{text}
</span>
)}
</div>
</div>
</div>
);
}
export function AssetsHeader({
activeTab,
setActiveTab,
theme,
setTheme,
lastUpdate,
loading,
selectedSubject,
setSelectedSubject,
subjects,
subjectDropdownOpen,
setSubjectDropdownOpen,
subjectSearch,
setSubjectSearch,
subjectDropdownRef,
}: AssetsHeaderProps) {
return (
<>
{/* Compact Header Bar */}
<div className="sticky top-0 z-40 -mx-3 -mt-3 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80 md:-mx-6 md:-mt-6">
{/* Title row */}
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">-BI</h1>
{/* Right: status + theme */}
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse inline-block" />
<span>{lastUpdate}</span>
</div>
{loading && (
<div className="flex items-center gap-1 text-[10px] text-gray-400">
<Loader2 className="animate-spin" size={10} />
</div>
)}
<div className="hidden sm:flex bg-gray-100 p-0.5 rounded-lg text-[10px]">
{(['soft','minimal','vibrant'] as const).map((t) => (
<button
key={t}
onClick={() => setTheme(t)}
className={`px-2 py-0.5 rounded-md transition-all ${theme === t ? 'bg-white text-blue-600 shadow-sm font-semibold' : 'text-gray-400 hover:text-gray-600'}`}
>
{t === 'soft' ? '柔和' : t === 'minimal' ? '简约' : '经典'}
</button>
))}
</div>
</div>
</div>
{/* 归属公司作用域筛选 (Scope Chip) */}
<div className="flex items-center justify-center px-4 pt-1">
<div className="relative" ref={subjectDropdownRef}>
<button
type="button"
onClick={() => {
setSubjectDropdownOpen((o) => !o);
setSubjectSearch('');
}}
className={`group inline-flex items-center gap-1.5 h-7 pl-2.5 pr-2 rounded-full border text-[11px] font-normal transition-all cursor-pointer ${
selectedSubject
? 'bg-blue-50 border-blue-300 text-blue-700 hover:bg-blue-100'
: 'bg-white border-gray-200 text-gray-500 hover:border-gray-300 hover:text-gray-700'
}`}
title={selectedSubject || '全部公司'}
>
<Filter size={11} className={selectedSubject ? 'text-blue-500' : 'text-gray-400'} />
<span className="max-w-[180px] truncate">
{selectedSubject || '全部公司'}
</span>
{selectedSubject ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
setSelectedSubject(null);
}}
className="ml-0.5 w-3.5 h-3.5 inline-flex items-center justify-center rounded-full text-blue-500 hover:bg-blue-200 hover:text-blue-700 cursor-pointer"
aria-label="清除归属公司筛选"
>
×
</span>
) : (
<ChevronDown size={11} className="text-gray-400" />
)}
</button>
{subjectDropdownOpen && (
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1.5 w-[320px] max-h-[380px] bg-white border border-gray-200 rounded-lg shadow-lg z-50 flex flex-col">
<div className="p-2 border-b border-gray-100">
<div className="relative">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
<input
autoFocus
value={subjectSearch}
onChange={(e) => setSubjectSearch(e.target.value)}
placeholder="搜索公司名"
className="w-full h-7 pl-6 pr-2 text-[11px] bg-gray-50 border border-gray-100 rounded focus:outline-none focus:border-blue-300 focus:bg-white"
/>
</div>
</div>
<div className="overflow-y-auto flex-1 py-1">
<button
type="button"
onClick={() => {
setSelectedSubject(null);
setSubjectDropdownOpen(false);
}}
className={`w-full flex items-center justify-between px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
!selectedSubject ? 'text-blue-600 font-medium' : 'text-gray-700'
}`}
>
<span className="flex items-center gap-1.5">
<span className={`w-1.5 h-1.5 rounded-full ${!selectedSubject ? 'bg-blue-500' : 'bg-gray-300'}`} />
</span>
<span className="text-[10px] text-gray-400">
{subjects.reduce((s, x) => s + x.total, 0)}
</span>
</button>
<div className="my-1 mx-3 border-t border-gray-100" />
{subjects
.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase()))
.map((s) => {
const active = selectedSubject === s.name;
return (
<button
key={s.name}
type="button"
onClick={() => {
setSelectedSubject(s.name);
setSubjectDropdownOpen(false);
}}
className={`w-full flex items-center justify-between gap-2 px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
active ? 'text-blue-600 font-medium bg-blue-50/40' : 'text-gray-700'
}`}
title={s.name}
>
<span className="flex items-center gap-1.5 min-w-0">
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${active ? 'bg-blue-500' : 'bg-gray-300'}`} />
<span className="truncate">{s.name}</span>
</span>
<span className="text-[10px] text-gray-400 flex-shrink-0 tabular-nums">
{s.total}
<span className="mx-1 text-gray-200">·</span>
<span className="text-green-500"> {s.operating}</span>
</span>
</button>
);
})}
{subjects.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())).length === 0 && (
<div className="px-3 py-6 text-center text-[11px] text-gray-400"></div>
)}
</div>
</div>
)}
</div>
</div>
{/* Tab row */}
<div className="flex items-center justify-center gap-1 px-4 pb-0 overflow-x-auto no-scrollbar">
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`relative px-4 py-2 text-[13px] font-normal transition-all whitespace-nowrap ${
activeTab === tab.id
? 'text-blue-600 font-medium'
: 'text-gray-400 hover:text-gray-500'
}`}
>
{tab.label}
{activeTab === tab.id && (
<motion.div
layoutId="activeTab"
className="absolute bottom-0 left-2 right-2 h-[1.5px] bg-blue-600 rounded-full"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
)}
</button>
))}
</div>
{/* Status row */}
<div className="flex items-center justify-center gap-4 py-1.5 text-[10px] text-gray-400">
<div className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
: {lastUpdate}
</div>
</div>
</div>
{/* OneOS 迁移提示滚动条 */}
<MarqueeBanner />
</>
);
}
@@ -0,0 +1,144 @@
import React from 'react';
import { Download, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { FlowDetailItem, FlowType } from '../api';
export const FLOW_META: Record<FlowType, { label: string; tone: string; chip: string }> = {
delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' },
returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' },
replaced: { label: '替换', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' },
};
interface FlowDetailModalProps {
selectedFlow: { date: string; type: FlowType } | null;
selectedFlowDetails: FlowDetailItem[];
setSelectedFlow: React.Dispatch<React.SetStateAction<{ date: string; type: FlowType } | null>>;
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
}
export function FlowDetailModal({
selectedFlow,
selectedFlowDetails,
setSelectedFlow,
exportFlowDetails,
}: FlowDetailModalProps) {
return (
<>
{/* Flow Detail Modal */}
<AnimatePresence>
{selectedFlow && (
<div className="fixed inset-0 z-[1000] flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-4">
<motion.div
data-testid="asset-flow-detail-modal"
initial={{ opacity: 0, y: 28, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
className="flex max-h-[88vh] w-full flex-col overflow-hidden rounded-t-3xl bg-white shadow-2xl sm:max-w-5xl sm:rounded-3xl"
>
<div className="border-b border-slate-100 bg-slate-950 px-4 py-4 text-white sm:px-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400"></div>
<h3 className="mt-1 text-lg font-black">
{selectedFlow.date} · {FLOW_META[selectedFlow.type].label}
</h3>
<div className="mt-1 text-[12px] font-bold text-slate-300">
{selectedFlowDetails.length}
</div>
</div>
<button
type="button"
onClick={() => setSelectedFlow(null)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
>
<X size={18} />
</button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => exportFlowDetails(selectedFlowDetails, `${selectedFlow.date}-${FLOW_META[selectedFlow.type].label}明细`)}
disabled={selectedFlowDetails.length === 0}
className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
>
<Download size={13} />
</button>
</div>
</div>
<div className="overflow-auto bg-slate-50 p-3 sm:p-4">
<div className="hidden overflow-hidden rounded-2xl border border-slate-100 bg-white lg:block">
<table className="w-full table-fixed text-left">
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
<tr>
<th className="w-28 px-3 py-3"></th>
<th className="w-40 px-3 py-3">{FLOW_META[selectedFlow.type].label}</th>
<th className="w-40 px-3 py-3"></th>
<th className="w-36 px-3 py-3"></th>
<th className="w-28 px-3 py-3"></th>
<th className="px-3 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-[12px] font-bold text-slate-700">
{selectedFlowDetails.map((item) => (
<tr key={item.id} className="hover:bg-blue-50/40">
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}</td>
<td className="px-3 py-3 text-slate-500">{item.eventTime || '-'}</td>
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
<td className="px-3 py-3">{item.department || '-'}</td>
<td className="px-3 py-3">{item.manager || '-'}</td>
<td className="px-3 py-3">{item.customerName || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="space-y-2 lg:hidden">
{selectedFlowDetails.map((item) => (
<div key={item.id} className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-base font-black text-slate-950">{item.plateNumber}</div>
<div className={`mt-1 inline-flex rounded-full border px-2 py-0.5 text-[10px] font-black ${FLOW_META[item.type].chip}`}>
{item.typeLabel}
</div>
</div>
<div className="text-right text-[10px] font-bold text-slate-400">
<div></div>
<div className="mt-0.5 text-[11px] text-blue-600">{item.submitTime?.slice(5, 16) || '-'}</div>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">{item.typeLabel}</div>
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 font-bold text-slate-700">{item.manager || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 font-bold text-slate-700">{item.department || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 line-clamp-2 font-bold text-slate-700">{item.customerName || '-'}</div>
</div>
</div>
</div>
))}
</div>
{selectedFlowDetails.length === 0 && (
<div className="rounded-2xl bg-white px-4 py-10 text-center text-sm font-bold text-slate-400"></div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</>
);
}
@@ -0,0 +1,64 @@
import { AssetSummarySection } from './overview/AssetSummarySection';
import { FlowStatisticsCard } from './overview/FlowStatisticsCard';
import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection';
import { SummaryMetrics } from './overview/SummaryMetrics';
import type { OverviewViewProps } from './overview/types';
export function OverviewView(props: OverviewViewProps) {
return (
<>
<SummaryMetrics
summary={props.summary}
operatingRate={props.operatingRate}
inventoryRate={props.inventoryRate}
pendingRate={props.pendingRate}
setVehicleSelection={props.setVehicleSelection}
/>
<FlowStatisticsCard
flowRange={props.flowRange}
setFlowRange={props.setFlowRange}
flowLoading={props.flowLoading}
flowStats={props.flowStats}
exportFlowDetails={props.exportFlowDetails}
flowDailyExpanded={props.flowDailyExpanded}
setFlowDailyExpanded={props.setFlowDailyExpanded}
setSelectedFlow={props.setSelectedFlow}
/>
<AssetSummarySection
processedData={props.processedData}
theme={props.theme}
expandedAssetTypes={props.expandedAssetTypes}
toggleAssetType={props.toggleAssetType}
expandedModels={props.expandedModels}
toggleModel={props.toggleModel}
setVehicleSelection={props.setVehicleSelection}
/>
<InventoryStatisticsSection
inventoryFilters={props.inventoryFilters}
setInventoryFilters={props.setInventoryFilters}
draftInventoryFilters={props.draftInventoryFilters}
setDraftInventoryFilters={props.setDraftInventoryFilters}
isInventoryFilterOpen={props.isInventoryFilterOpen}
setIsInventoryFilterOpen={props.setIsInventoryFilterOpen}
uniqueInventoryRegions={props.uniqueInventoryRegions}
uniqueInventoryCities={props.uniqueInventoryCities}
uniqueInventoryBrands={props.uniqueInventoryBrands}
uniqueInventoryTypes={props.uniqueInventoryTypes}
uniqueInventoryModelsForType={props.uniqueInventoryModelsForType}
filteredInventoryStats={props.filteredInventoryStats}
inventoryTab={props.inventoryTab}
setInventoryTab={props.setInventoryTab}
inventoryByRegion={props.inventoryByRegion}
expandedInventoryRegions={props.expandedInventoryRegions}
toggleInventoryRegion={props.toggleInventoryRegion}
inventoryByModel={props.inventoryByModel}
expandedInventoryTypes={props.expandedInventoryTypes}
toggleInventoryType={props.toggleInventoryType}
setVehicleSelection={props.setVehicleSelection}
/>
</>
);
}
@@ -0,0 +1,281 @@
import React from 'react';
import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import Blur from '../../../components/Blur';
import { SearchSelect } from '../../../components/SearchSelect';
import type { WeeklyDetailItem } from '../api';
import type { ModalVehicleFilters, VehicleModalSelection } from '../model';
import type { VehicleListItem } from '../types';
interface VehicleDetailModalProps {
selection: VehicleModalSelection | null;
setSelection: React.Dispatch<React.SetStateAction<VehicleModalSelection | null>>;
isFilterExpanded: boolean;
setIsFilterExpanded: React.Dispatch<React.SetStateAction<boolean>>;
filters: ModalVehicleFilters;
setFilters: React.Dispatch<React.SetStateAction<ModalVehicleFilters>>;
plates: string[];
models: string[];
brands: string[];
locations: string[];
loading: boolean;
weeklyDetails: WeeklyDetailItem[];
filteredWeeklyDetails: WeeklyDetailItem[];
filteredVehicles: VehicleListItem[];
}
export function VehicleDetailModal({
selection: showPlateNumbers,
setSelection: setShowPlateNumbers,
isFilterExpanded: isModalFilterExpanded,
setIsFilterExpanded: setIsModalFilterExpanded,
filters: modalFilters,
setFilters: setModalFilters,
plates: uniqueModalPlates,
models: uniqueModalModels,
brands: uniqueModalBrands,
locations: uniqueModalLocations,
loading: modalLoading,
weeklyDetails: modalWeeklyDetail,
filteredWeeklyDetails: filteredModalWeeklyDetail,
filteredVehicles: filteredModalVehicles,
}: VehicleDetailModalProps) {
return (
<>
{/* Vehicle Detail Modal */}
<AnimatePresence>
{showPlateNumbers && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
className={`bg-white rounded-xl shadow-2xl w-full max-w-[95vw] ${showPlateNumbers.source === 'customer' ? 'lg:max-w-6xl' : 'lg:max-w-4xl'} overflow-hidden flex flex-col max-h-[85vh] sm:max-h-[90vh] min-h-[40vh]`}
>
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-slate-800 text-white shrink-0">
<div>
<h3 className="font-bold text-base flex items-center gap-2">
<Truck size={18} className="text-blue-400" />
{showPlateNumbers.title || (
(showPlateNumbers.manager ? `${showPlateNumbers.manager}${showPlateNumbers.type || ''}车辆` :
showPlateNumbers.customer ? `${showPlateNumbers.customer}${showPlateNumbers.type || ''}车辆` :
showPlateNumbers.batch === 'All' ? '全量批次' : `${showPlateNumbers.batch} 批次`) + ' - 运营明细'
)}
</h3>
<p className="text-[10px] opacity-60 mt-0.5">
{showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} |
{showPlateNumbers.category === 'Pending' ? '待交车' :
showPlateNumbers.category === 'Delivered' ? '本周已交车' :
showPlateNumbers.category === 'Returned' ? '已还车' :
showPlateNumbers.category === 'Replaced' ? '已替换' :
showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` :
showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}
</p>
</div>
<button onClick={() => setShowPlateNumbers(null)} className="hover:bg-white/10 p-2 rounded-full transition-colors">
<PlusCircle className="rotate-45" size={24} />
</button>
</div>
{/* Modal Filters */}
<div className="px-4 py-2 bg-slate-50 border-b border-gray-200 shrink-0">
<div
className="flex justify-between items-center cursor-pointer py-1"
onClick={() => setIsModalFilterExpanded(!isModalFilterExpanded)}
>
<div className="flex items-center gap-2 text-slate-600">
<Filter size={14} />
<span className="text-xs font-bold"></span>
</div>
<div className="flex items-center gap-3">
{/* Quick Search always visible when collapsed */}
{!isModalFilterExpanded && (
<div className="w-40 sm:w-64" onClick={(e) => e.stopPropagation()}>
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="快速搜索车牌..." className="text-[11px] py-1 px-2" />
</div>
)}
<motion.div
animate={{ rotate: isModalFilterExpanded ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown size={16} className="text-slate-400" />
</motion.div>
</div>
</div>
<AnimatePresence>
{isModalFilterExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 py-3 border-t border-gray-100 mt-1">
<div className="space-y-1">
<label className="block text-[10px] text-gray-500 font-medium"></label>
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="全部车牌" className="text-[11px] py-1.5 px-2" />
</div>
<div className="space-y-1">
<label className="block text-[10px] text-gray-500 font-medium"></label>
<select value={modalFilters.model} onChange={(e) => setModalFilters({...modalFilters, model: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value=""></option>
{uniqueModalModels.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
<div className="space-y-1">
<label className="block text-[10px] text-gray-500 font-medium"></label>
<select value={modalFilters.brand} onChange={(e) => setModalFilters({...modalFilters, brand: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value=""></option>
{uniqueModalBrands.map(b => <option key={b} value={b}>{b}</option>)}
</select>
</div>
<div className="space-y-1">
<label className="block text-[10px] text-gray-500 font-medium"></label>
<select value={modalFilters.location} onChange={(e) => setModalFilters({...modalFilters, location: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value=""></option>
{uniqueModalLocations.map(l => <option key={l} value={l}>{l}</option>)}
</select>
</div>
</div>
<div className="flex justify-end pb-2">
<button
onClick={() => setModalFilters({ plateNumber: '', model: '', brand: '', location: '' })}
className="text-[10px] text-blue-500 hover:text-blue-600 font-medium"
>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="flex-1 overflow-auto p-0 sm:p-4 bg-gray-50 min-h-0 overscroll-contain">
{modalLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="animate-spin text-blue-500" size={32} />
</div>
) : modalWeeklyDetail.length > 0 ? (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden w-full">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-700 text-white text-[10px] uppercase tracking-wider sticky top-0 z-20 shadow-sm">
<th className="p-2 font-semibold border-r border-slate-600 text-center"></th>
<th className="p-2 font-semibold border-r border-slate-600 text-center"></th>
<th className="p-2 font-semibold text-center"></th>
</tr>
</thead>
<tbody className="text-[11px]">
{filteredModalWeeklyDetail.map((v, i) => (
<tr key={`${v.truck_id}-${i}`} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors ${i % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center"><Blur>{v.plate_number}</Blur></td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{v.customer_name || '—'}</Blur></td>
<td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm w-full">
<table className="min-w-full w-max text-left border-collapse">
<thead className="sticky top-0 z-20 shadow-sm">
<tr className="bg-slate-700 text-white text-[10px] uppercase tracking-wider whitespace-nowrap">
{showPlateNumbers.source === 'customer' ? (
<>
<th className="p-2 font-semibold border-r border-slate-600 w-24"></th>
<th className="p-2 font-semibold border-r border-slate-600 w-24"></th>
<th className="p-2 font-semibold border-r border-slate-600 w-16"></th>
<th className="p-2 font-semibold border-r border-slate-600 w-16"></th>
<th className="p-2 font-semibold border-r border-slate-600 w-48"></th>
<th className="p-2 font-semibold border-r border-slate-600 w-48"></th>
<th className="p-2 font-semibold border-r border-slate-600 w-24"></th>
<th className="p-2 font-semibold border-r border-slate-600 w-16 text-center"></th>
<th className="p-2 font-semibold border-r border-slate-600 w-24 text-center"></th>
<th className="p-2 font-semibold border-r border-slate-600 w-24 text-center"></th>
<th className="p-2 font-semibold border-r border-slate-600 w-24"></th>
<th className="p-2 font-semibold border-r border-slate-600 w-20 text-center"></th>
<th className="p-2 font-semibold w-48"></th>
</>
) : (
<>
<th className="p-2 font-semibold border-r border-slate-600 text-center"></th>
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
<th className="p-2 font-semibold border-r border-slate-600 text-center"></th>
)}
<th className="p-2 font-semibold border-r border-slate-600 text-center"></th>
<th className="p-2 font-semibold border-r border-slate-600 text-center"></th>
<th className="p-2 font-semibold text-center"></th>
</>
)}
</tr>
</thead>
<tbody className="text-[11px]">
{filteredModalVehicles.map((v, idx) => (
<tr key={v.id} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors whitespace-nowrap ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
{showPlateNumbers.source === 'customer' ? (
<>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.departmentName || '—'}</td>
<td className="p-2 border-r border-gray-100 font-medium text-gray-700"><Blur>{v.customerManager || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.brandLabel || '—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.type}</td>
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]"><Blur>{v.subjectOrg || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 font-bold text-gray-800"><Blur>{v.customerName || '—'}</Blur></td>
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 text-center">
<span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${
v.status === 'Operating' ? 'bg-green-100 text-green-700' :
v.status === 'Inventory' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'
}`}>
{v.status === 'Operating' ? '在租' : v.status === 'Inventory' ? '库存' : '异常'}
</span>
</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.location === '其他' ? '对接中' : v.location}</td>
<td className="p-2 border-r border-gray-100 text-center font-bold text-orange-600">{'—'}</td>
<td className="p-2 text-gray-500 text-[10px]"><Blur>{v.orgName || '—'}</Blur></td>
</>
) : (
<>
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center"><Blur>{v.customerName || '—'}</Blur></td>
)}
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td>
<td className="p-2 text-gray-600 text-center">{v.location === '其他' ? '对接中' : v.location}</td>
</>
)}
</tr>
))}
{filteredModalVehicles.length === 0 && (
<tr>
<td colSpan={showPlateNumbers.source === 'customer' ? 13 : ((showPlateNumbers.source === 'asset' || showPlateNumbers.category === 'Inventory') ? 4 : 5)} className="p-8 text-center text-gray-400 italic">
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
<div className="p-4 bg-white border-t border-gray-100 flex justify-between items-center shrink-0">
<div className="text-xs text-gray-500">
<span className="font-bold text-blue-600">{filteredModalWeeklyDetail.length > 0 ? filteredModalWeeklyDetail.length : filteredModalVehicles.length}</span>
</div>
<button
onClick={() => setShowPlateNumbers(null)}
className="px-6 py-2 bg-slate-800 text-white rounded-lg text-xs font-bold hover:bg-slate-700 transition-colors shadow-sm"
>
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</>
);
}
@@ -0,0 +1,195 @@
import React from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { AssetSummarySectionProps } from './types';
export function AssetSummaryDesktopTable({
processedData,
theme,
expandedAssetTypes,
toggleAssetType,
expandedModels,
toggleModel,
setVehicleSelection: setShowPlateNumbers,
}: AssetSummarySectionProps) {
return (
<div className="hidden lg:block overflow-x-auto">
<table className="w-full text-left border-collapse table-fixed min-w-[1200px]">
<thead>
<tr className="bg-gray-50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-100">
<th className="p-3 font-semibold border-r border-gray-100 w-24"></th>
<th className="p-3 font-semibold border-r border-gray-100 w-48"></th>
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/30 w-24"></th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24"></th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">-</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">-广</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">-</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">-</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">-</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24"></th>
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-green-50/30 w-24"></th>
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/20 w-24"></th>
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-orange-50/20 w-24"></th>
<th className="p-3 font-semibold text-center bg-purple-50/20 w-24"></th>
</tr>
</thead>
<tbody className="text-xs">
{processedData.map((typeGroup) => (
<React.Fragment key={typeGroup.type}>
{/* Category Header Row */}
<tr className={`border-b border-gray-100 cursor-pointer transition-all ${
theme === 'vibrant' ? 'bg-blue-600 text-white hover:bg-blue-700' :
theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 hover:bg-gray-50' :
'bg-blue-50/50 hover:bg-blue-50 transition-colors'
}`}
onClick={() => toggleAssetType(typeGroup.type)}>
<td colSpan={14} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{expandedAssetTypes.has(typeGroup.type) ?
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} /> :
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-gray-400'} />
}
<span>{typeGroup.type}</span>
</div>
<div className={`flex gap-6 text-[11px] font-normal mr-4 ${theme === 'vibrant' ? 'text-white/80' : 'text-gray-500'}`}>
<span> <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
<span> <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
<span> <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
</div>
</div>
</td>
</tr>
<AnimatePresence>
{expandedAssetTypes.has(typeGroup.type) && typeGroup.models.map((model) => (
<React.Fragment key={model.model}>
<motion.tr
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer ${expandedModels.has(model.model) ? 'bg-blue-50/10' : ''}`}
onClick={() => toggleModel(model.model)}
>
<td className="p-3 border-r border-gray-100 text-gray-300 text-center italic">{typeGroup.type}</td>
<td className="p-3 border-r border-gray-100 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
<span className={expandedModels.has(model.model) ? 'font-bold text-blue-700' : ''}>{model.model}</span>
</div>
<div className="flex gap-3 text-[9px] font-normal text-gray-400">
<span> <span className="font-bold text-gray-600">{model.total}</span></span>
<span> <span className="font-bold text-blue-500">{model.inventory}</span></span>
<span> <span className="font-bold text-green-500">{model.operating}</span></span>
</div>
</td>
<td className="p-3 text-center border-r border-gray-100 font-medium">
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model });
}}
className="text-blue-500 hover:underline font-medium"
>
{model.total}
</button>
</td>
<td className="p-3 text-center border-r border-gray-100 font-medium">
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` });
}}
className="text-blue-500 hover:underline font-medium"
>
{model.inventory}
</button>
</td>
{['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => (
<td key={reg} className="p-3 text-center border-r border-gray-100">
{model.inventoryRegions[reg] > 0 ? (
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory', source: 'asset', title: `${model.model} - ${reg} 库存` });
}}
className="text-blue-500 hover:underline font-medium"
>
{model.inventoryRegions[reg]}
</button>
) : ''}
</td>
))}
<td className="p-3 text-center border-r border-gray-100">
{model.pending > 0 ? (
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` });
}}
className="text-blue-500 hover:underline font-medium"
>
{model.pending}
</button>
) : model.pending}
</td>
<td className="p-3 text-center border-r border-gray-100 text-green-600 font-bold bg-green-50/10">
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` });
}}
className="text-green-600 hover:underline font-bold"
>
{model.operating}
</button>
</td>
<td className="p-3 text-center border-r border-gray-100 text-blue-600 bg-blue-50/5">
{model.weeklyDelivered > 0 ? (
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` });
}}
className="text-blue-500 hover:underline font-medium"
>
{model.weeklyDelivered}
</button>
) : model.weeklyDelivered}
</td>
<td className="p-3 text-center border-r border-gray-100 text-orange-600 bg-orange-50/5">
{model.weeklyReturned > 0 ? (
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` });
}}
className="text-blue-500 hover:underline font-medium"
>
{model.weeklyReturned}
</button>
) : model.weeklyReturned}
</td>
<td className="p-3 text-center text-purple-600 bg-purple-50/5 font-medium">
{model.weeklyReplaced > 0 ? (
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` });
}}
className="text-blue-500 hover:underline font-medium"
>
{model.weeklyReplaced}
</button>
) : model.weeklyReplaced}
</td>
</motion.tr>
</React.Fragment>
))}
</AnimatePresence>
</React.Fragment>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,127 @@
import { ChevronDown, ChevronRight } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { AssetSummarySectionProps } from './types';
export function AssetSummaryMobileCards({
processedData,
theme,
expandedAssetTypes,
toggleAssetType,
expandedModels,
toggleModel,
setVehicleSelection: setShowPlateNumbers,
}: AssetSummarySectionProps) {
return (
<div className="lg:hidden p-4 space-y-4">
{processedData.map((typeGroup) => (
<div key={typeGroup.type} className="space-y-3">
<div
className={`px-3 py-2 rounded flex justify-between items-center shadow-sm cursor-pointer transition-all ${
theme === 'vibrant' ? 'bg-blue-600 text-white active:bg-blue-700' :
theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 text-gray-800 active:bg-gray-50' :
'bg-blue-50 border border-blue-100 text-blue-700 active:bg-blue-100'
}`}
onClick={() => toggleAssetType(typeGroup.type)}
>
<div className="flex items-center gap-2">
{expandedAssetTypes.has(typeGroup.type) ?
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} /> :
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-blue-300'} />
}
<span className="text-xs font-bold">{typeGroup.type}</span>
</div>
<div className={`flex gap-3 text-[9px] font-normal ${theme === 'vibrant' ? 'opacity-90' : 'text-gray-500'}`}>
<span> <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
<span> <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
<span> <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
</div>
</div>
<AnimatePresence>
{expandedAssetTypes.has(typeGroup.type) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="space-y-3 overflow-hidden"
>
{typeGroup.models.map((model) => (
<div key={model.model} className="bg-white rounded-lg border border-gray-100 shadow-sm overflow-hidden">
<div
className="p-3 flex justify-between items-center cursor-pointer active:bg-gray-50"
onClick={() => toggleModel(model.model)}
>
<div className="flex items-center gap-2">
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
<span className="text-xs font-bold text-gray-700">{model.model}</span>
</div>
<div className="flex gap-2">
<span className="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-blue-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model }); }}> {model.total}</span>
<span className="text-[10px] bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-orange-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` }); }}> {model.inventory}</span>
<span className="text-[10px] bg-green-50 text-green-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-green-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` }); }}> {model.operating}</span>
</div>
</div>
{expandedModels.has(model.model) && (
<div className="px-3 pb-3 pt-1 border-t border-gray-50 bg-gray-50/30">
<div className="grid grid-cols-2 gap-y-3 gap-x-4 mt-2">
<div className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` })}>
<span className="text-[10px] text-gray-400"></span>
<span className="text-xs font-bold text-blue-600">{model.inventory}</span>
</div>
<div className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` })}>
<span className="text-[10px] text-gray-400"></span>
<span className="text-xs font-bold text-gray-600">{model.pending}</span>
</div>
<div className="col-span-2 grid grid-cols-5 gap-1 py-2 border-y border-gray-100">
{['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => (
<div key={reg} className="text-center">
<div className="text-[8px] text-gray-400 mb-0.5">{reg === '嘉兴' ? '浙' : reg === '广东' ? '粤' : reg === '北京' ? '京' : reg === '新疆' ? '新' : '其'}</div>
{model.inventoryRegions[reg] > 0 ? (
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory', source: 'asset', title: `${model.model} - ${reg} 库存` });
}}
className="text-[10px] font-bold text-blue-500 hover:underline"
>
{model.inventoryRegions[reg]}
</button>
) : (
<div className="text-[10px] font-bold text-gray-300">-</div>
)}
</div>
))}
</div>
<div className="col-span-2 grid grid-cols-3 gap-2 pt-1">
<div className="bg-blue-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-blue-100/50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` })}>
<span className="text-[8px] text-gray-400 mb-1"></span>
<span className="text-xs font-bold text-blue-600">{model.weeklyDelivered}</span>
</div>
<div className="bg-orange-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-orange-100/50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` })}>
<span className="text-[8px] text-gray-400 mb-1"></span>
<span className="text-xs font-bold text-orange-600">{model.weeklyReturned}</span>
</div>
<div className="bg-purple-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-purple-100/50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` })}>
<span className="text-[8px] text-gray-400 mb-1"></span>
<span className="text-xs font-bold text-purple-600">{model.weeklyReplaced}</span>
</div>
</div>
</div>
</div>
)}
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
);
}
@@ -0,0 +1,26 @@
import { Info } from 'lucide-react';
import { AssetSummaryDesktopTable } from './AssetSummaryDesktopTable';
import { AssetSummaryMobileCards } from './AssetSummaryMobileCards';
import type { AssetSummarySectionProps } from './types';
export function AssetSummarySection(props: AssetSummarySectionProps) {
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden mb-6">
<div className="p-4 border-b border-gray-50 bg-gray-50/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
<div className="flex flex-wrap items-center gap-4 sm:gap-6">
<h2 className="text-sm font-bold text-gray-700"></h2>
<div className="hidden md:flex items-center gap-1 text-[10px] text-blue-500 bg-blue-50 px-2 py-0.5 rounded">
<Info size={10} />
</div>
</div>
</div>
{/* Desktop View Table */}
<AssetSummaryDesktopTable {...props} />
{/* Mobile View Cards for Asset Summary */}
<AssetSummaryMobileCards {...props} />
</div>
);
}
@@ -0,0 +1,153 @@
import { CalendarDays, ChevronDown, Download, Loader2 } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { FlowType } from '../../api';
import { FLOW_META } from '../FlowDetailModal';
import type { FlowStatisticsCardProps } from './types';
const FLOW_TYPES: FlowType[] = ['delivered', 'returned', 'replaced'];
export function FlowStatisticsCard({
flowRange,
setFlowRange,
flowLoading,
flowStats,
exportFlowDetails,
flowDailyExpanded,
setFlowDailyExpanded,
setSelectedFlow,
}: FlowStatisticsCardProps) {
return (
<div className="grid grid-cols-1 gap-3 mb-4">
<div data-testid="asset-flow-card" className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[13px] font-black text-slate-700">
<CalendarDays size={14} className="text-blue-500" />
<span></span>
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => exportFlowDetails()}
disabled={!flowStats?.details.length}
className="inline-flex h-8 items-center justify-center gap-1 rounded-xl border border-slate-200 bg-slate-50 px-2.5 text-[11px] font-black text-slate-600 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
>
<Download size={13} />
</button>
</div>
</div>
<div className="mt-2 rounded-xl border border-slate-100 bg-slate-50/80 px-2 py-1.5">
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
<span className="block text-[9px] font-black text-slate-400"></span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className="text-[12px] font-black text-slate-700">{flowRange.start.replaceAll('-', '/')}</span>
<CalendarDays size={12} className="text-slate-300" />
</span>
<input
type="date"
value={flowRange.start}
onChange={(e) => setFlowRange((prev) => ({ ...prev, start: e.target.value }))}
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
<div className="rounded-full bg-slate-200/70 px-2 py-0.5 text-[9px] font-black text-slate-400"></div>
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
<span className="block text-[9px] font-black text-slate-400"></span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className="text-[12px] font-black text-slate-700">{flowRange.end.replaceAll('-', '/')}</span>
<CalendarDays size={12} className="text-slate-300" />
</span>
<input
type="date"
value={flowRange.end}
onChange={(e) => setFlowRange((prev) => ({ ...prev, end: e.target.value }))}
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
</div>
</div>
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5">
<div className="grid grid-cols-4 items-center text-center">
<div className="px-2">
<div className="text-lg font-black leading-none text-slate-950">{flowStats?.totals.total ?? 0}</div>
<div className="mt-1 text-[10px] font-black text-slate-400"></div>
</div>
{FLOW_TYPES.map((type) => (
<div key={type} className="border-l border-slate-200/70 px-2">
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>
{flowStats?.totals[type] ?? 0}
</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
</div>
))}
</div>
</div>
<button
type="button"
data-testid="asset-flow-daily-toggle"
onClick={() => setFlowDailyExpanded((prev) => !prev)}
className="mt-2 flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[12px] font-black text-slate-500 transition hover:border-blue-100 hover:bg-blue-50/50 hover:text-blue-600"
>
<span>{flowDailyExpanded ? '收起每日明细' : '展开每日明细'}</span>
<span className="flex items-center gap-2 text-[11px] text-slate-400">
{flowStats?.daily.length ?? 0}
<ChevronDown size={15} className={`transition-transform ${flowDailyExpanded ? 'rotate-180' : ''}`} />
</span>
</button>
<AnimatePresence initial={false}>
{flowDailyExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="mt-3 max-h-[280px] space-y-2 overflow-y-auto pr-1">
{flowLoading && (
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">...</div>
)}
{!flowLoading && flowStats?.daily.map((day) => (
<div key={day.date} className="rounded-2xl border border-slate-100 bg-white px-3 py-3 shadow-sm">
<div className="flex items-center justify-between">
<div className="text-[11px] font-black text-slate-400">{day.date}</div>
<div className="text-[10px] font-bold italic text-slate-300"></div>
</div>
<div className="mt-2 grid grid-cols-4 items-center text-center">
<div className="px-2">
<div className="text-lg font-black leading-none text-slate-900">{day.total}</div>
<div className="mt-1 text-[10px] font-black text-slate-400"></div>
</div>
{FLOW_TYPES.map((type) => {
const count = day[type];
return (
<button
key={type}
type="button"
data-testid={`asset-flow-cell-${day.date}-${type}`}
disabled={count === 0}
onClick={() => setSelectedFlow({ date: day.date, type })}
className="border-l border-slate-100 px-2 text-center transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-35"
>
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>{count}</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
</button>
);
})}
</div>
</div>
))}
{!flowLoading && !flowStats?.daily.length && (
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400"></div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
@@ -0,0 +1,153 @@
import React from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { InventoryStatisticsSectionProps } from './types';
type InventoryDesktopTableProps = Pick<
InventoryStatisticsSectionProps,
| 'inventoryTab'
| 'inventoryByRegion'
| 'expandedInventoryRegions'
| 'toggleInventoryRegion'
| 'inventoryByModel'
| 'expandedInventoryTypes'
| 'toggleInventoryType'
| 'setVehicleSelection'
>;
export function InventoryDesktopTable({
inventoryTab,
inventoryByRegion,
expandedInventoryRegions,
toggleInventoryRegion,
inventoryByModel,
expandedInventoryTypes,
toggleInventoryType,
setVehicleSelection: setShowPlateNumbers,
}: InventoryDesktopTableProps) {
return (
<div className="hidden lg:block overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50 text-[11px] text-slate-500 uppercase tracking-wider border-b border-slate-100">
<th className="p-3 font-semibold w-64">{inventoryTab === 'region' ? '区域 / 城市' : '车型分类 / 型号'}</th>
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '品牌' : '品牌'}</th>
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '车型' : '所在区域/城市'}</th>
<th className="p-3 font-semibold text-center w-32"></th>
</tr>
</thead>
<tbody className="text-xs">
{inventoryTab === 'region' ? (
Object.entries(inventoryByRegion).map(([region, cities]) => (
<React.Fragment key={region}>
<tr
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
onClick={() => toggleInventoryRegion(region)}
>
<td colSpan={4} className="p-3 font-bold text-slate-700">
<div className="flex items-center gap-2">
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span>{region}</span>
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: 'All', location: region, category: 'Inventory', source: 'asset', title: `库存统计 - ${region}` });
}}>
( {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} )
</span>
</div>
</td>
</tr>
<AnimatePresence>
{expandedInventoryRegions.has(region) && Object.entries(cities).map(([city, stats]) => (
<React.Fragment key={city}>
{stats.map((stat, idx) => (
<motion.tr
key={`${city}-${stat.model}-${idx}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
>
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
{idx === 0 ? <span className="font-medium text-slate-600">{city}</span> : ''}
</td>
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.model}</td>
<td className="p-3 text-center font-bold text-blue-600">
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` });
}}
className="text-blue-600 hover:underline font-bold"
>
{stat.quantity}
</button>
</td>
</motion.tr>
))}
</React.Fragment>
))}
</AnimatePresence>
</React.Fragment>
))
) : (
Object.entries(inventoryByModel).map(([type, models]) => (
<React.Fragment key={type}>
<tr
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
onClick={() => toggleInventoryType(type)}
>
<td colSpan={4} className="p-3 font-bold text-slate-700">
<div className="flex items-center gap-2">
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span>{type}</span>
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', type: type, category: 'Inventory', source: 'asset', title: `库存统计 - ${type}` });
}}>
( {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} )
</span>
</div>
</td>
</tr>
<AnimatePresence>
{expandedInventoryTypes.has(type) && Object.entries(models).map(([model, stats]) => (
<React.Fragment key={model}>
{stats.map((stat, idx) => (
<motion.tr
key={`${model}-${stat.region}-${stat.city}-${idx}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
>
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
{idx === 0 ? <span className="font-medium text-slate-600">{model}</span> : ''}
</td>
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.region} / {stat.city}</td>
<td className="p-3 text-center font-bold text-blue-600">
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` });
}}
className="text-blue-600 hover:underline font-bold"
>
{stat.quantity}
</button>
</td>
</motion.tr>
))}
</React.Fragment>
))}
</AnimatePresence>
</React.Fragment>
))
)}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,117 @@
import { ChevronDown, ChevronRight } from 'lucide-react';
import type { InventoryStatisticsSectionProps } from './types';
type InventoryMobileListProps = Pick<
InventoryStatisticsSectionProps,
| 'inventoryTab'
| 'inventoryByRegion'
| 'expandedInventoryRegions'
| 'toggleInventoryRegion'
| 'inventoryByModel'
| 'expandedInventoryTypes'
| 'toggleInventoryType'
| 'setVehicleSelection'
>;
export function InventoryMobileList({
inventoryTab,
inventoryByRegion,
expandedInventoryRegions,
toggleInventoryRegion,
inventoryByModel,
expandedInventoryTypes,
toggleInventoryType,
setVehicleSelection: setShowPlateNumbers,
}: InventoryMobileListProps) {
return (
<div className="lg:hidden p-3 space-y-3">
{inventoryTab === 'region' ? (
Object.entries(inventoryByRegion).map(([region, cities]) => (
<div key={region} className="border border-slate-100 rounded overflow-hidden">
<div
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
onClick={() => toggleInventoryRegion(region)}
>
<div className="flex items-center gap-2">
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span className="text-xs font-bold text-slate-700">{region}</span>
</div>
<span className="text-[10px] font-bold text-blue-600">
{Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)}
</span>
</div>
{expandedInventoryRegions.has(region) && (
<div className="p-2 space-y-2 bg-white">
{Object.entries(cities).map(([city, stats]) => (
<div key={city} className="border-l-2 border-slate-200 pl-3 py-1">
<div className="text-[10px] font-bold text-slate-500 mb-2">{city}</div>
<div className="space-y-2">
{stats.map((stat, idx) => (
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
<div className="flex flex-col">
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
<span className="text-slate-700 font-medium">{stat.model}</span>
</div>
<button
onClick={() => setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` })}
className="font-bold text-blue-600 hover:underline"
>
{stat.quantity}
</button>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
))
) : (
Object.entries(inventoryByModel).map(([type, models]) => (
<div key={type} className="border border-slate-100 rounded overflow-hidden">
<div
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
onClick={() => toggleInventoryType(type)}
>
<div className="flex items-center gap-2">
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span className="text-xs font-bold text-slate-700">{type}</span>
</div>
<span className="text-[10px] font-bold text-blue-600">
{Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)}
</span>
</div>
{expandedInventoryTypes.has(type) && (
<div className="p-2 space-y-2 bg-white">
{Object.entries(models).map(([model, stats]) => (
<div key={model} className="border-l-2 border-slate-200 pl-3 py-1">
<div className="text-[10px] font-bold text-slate-500 mb-2">{model}</div>
<div className="space-y-2">
{stats.map((stat, idx) => (
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
<div className="flex flex-col">
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
<span className="text-slate-700 font-medium">{stat.region} / {stat.city}</span>
</div>
<button
onClick={() => setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` })}
className="font-bold text-blue-600 hover:underline"
>
{stat.quantity}
</button>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
))
)}
</div>
);
}
@@ -0,0 +1,203 @@
import { Filter } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { InventoryDesktopTable } from './InventoryDesktopTable';
import { InventoryMobileList } from './InventoryMobileList';
import type { InventoryStatisticsSectionProps } from './types';
export function InventoryStatisticsSection({
inventoryFilters,
setInventoryFilters,
draftInventoryFilters,
setDraftInventoryFilters,
isInventoryFilterOpen,
setIsInventoryFilterOpen,
uniqueInventoryRegions,
uniqueInventoryCities,
uniqueInventoryBrands,
uniqueInventoryTypes,
uniqueInventoryModelsForType,
filteredInventoryStats,
inventoryTab,
setInventoryTab,
inventoryByRegion,
expandedInventoryRegions,
toggleInventoryRegion,
inventoryByModel,
expandedInventoryTypes,
toggleInventoryType,
setVehicleSelection: setShowPlateNumbers,
}: InventoryStatisticsSectionProps) {
const listProps = {
inventoryTab,
inventoryByRegion,
expandedInventoryRegions,
toggleInventoryRegion,
inventoryByModel,
expandedInventoryTypes,
toggleInventoryType,
setVehicleSelection: setShowPlateNumbers,
};
return (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm mb-6 overflow-hidden min-h-[420px]">
<div className="p-3 sm:p-4 border-b border-gray-50 bg-white flex items-center justify-between relative">
<div className="flex items-center gap-3">
<div className="w-1.5 h-6 bg-blue-600 rounded-full"></div>
<div className="flex flex-col">
<h2 className="text-lg font-bold text-gray-800 leading-tight"></h2>
<span className="text-[10px] text-gray-400 font-medium"></span>
</div>
</div>
<div className="flex items-center gap-6 ml-auto pr-2">
<div className="flex flex-col items-end cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
<span className="text-[10px] text-gray-400 font-bold tracking-wider uppercase mb-0.5"></span>
<span className="text-2xl font-black text-gray-900 leading-none">
{filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)}
<span className="text-xs font-bold text-gray-400 ml-1"></span>
</span>
</div>
<div className="relative">
<button
onClick={() => { if (!isInventoryFilterOpen) setDraftInventoryFilters({...inventoryFilters}); setIsInventoryFilterOpen(!isInventoryFilterOpen); }}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
isInventoryFilterOpen || (inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model)
? 'bg-blue-600 text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<Filter size={14} />
<span></span>
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
<span className="w-2 h-2 bg-white rounded-full animate-pulse"></span>
)}
</button>
<AnimatePresence>
{isInventoryFilterOpen && (
<>
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-slate-100 z-50 p-4"
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-xs font-bold text-slate-800"> - </h3>
<button onClick={() => setDraftInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[10px] text-blue-500 hover:underline"></button>
</div>
<div className="space-y-3 text-left">
<div>
<label className="text-[10px] text-slate-400 block mb-1"></label>
<select value={draftInventoryFilters.region} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, region: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value=""></option>
{uniqueInventoryRegions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-slate-400 block mb-1"></label>
<select value={draftInventoryFilters.city} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, city: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value=""></option>
{uniqueInventoryCities.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-slate-400 block mb-1"></label>
<select value={draftInventoryFilters.brand} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, brand: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value=""></option>
{uniqueInventoryBrands.map(b => <option key={b} value={b}>{b}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-slate-400 block mb-1"></label>
<select value={draftInventoryFilters.type} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, type: e.target.value, model: ''})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value=""></option>
{uniqueInventoryTypes.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-slate-400 block mb-1"></label>
<select value={draftInventoryFilters.model} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, model: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value=""></option>
{uniqueInventoryModelsForType.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
</div>
<button onClick={() => { setInventoryFilters({...draftInventoryFilters}); setIsInventoryFilterOpen(false); }} className="w-full mt-4 py-2 bg-blue-600 text-white rounded-lg text-xs font-bold hover:bg-blue-700 transition-colors"></button>
</motion.div>
</>
)}
</AnimatePresence>
</div>
</div>
</div>
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
<div className="flex bg-gray-200/50 p-1 rounded-lg w-fit shadow-inner">
<button
onClick={() => setInventoryTab('region')}
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
inventoryTab === 'region' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
</button>
<button
onClick={() => setInventoryTab('model')}
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
inventoryTab === 'model' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
</button>
</div>
</div>
{/* Active Filters Bar */}
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
<div className="px-4 py-2 border-b border-gray-100 flex flex-wrap gap-2 items-center">
{inventoryFilters.region && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
: {inventoryFilters.region}
<button onClick={() => setInventoryFilters({...inventoryFilters, region: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
{inventoryFilters.city && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
: {inventoryFilters.city}
<button onClick={() => setInventoryFilters({...inventoryFilters, city: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
{inventoryFilters.brand && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
: {inventoryFilters.brand}
<button onClick={() => setInventoryFilters({...inventoryFilters, brand: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
{inventoryFilters.type && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
: {inventoryFilters.type}
<button onClick={() => setInventoryFilters({...inventoryFilters, type: '', model: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
{inventoryFilters.model && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
: {inventoryFilters.model}
<button onClick={() => setInventoryFilters({...inventoryFilters, model: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
<button onClick={() => setInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[11px] text-red-500 font-bold ml-auto hover:text-red-600"></button>
</div>
)}
{/* Desktop View Table */}
<InventoryDesktopTable {...listProps} />
{/* Mobile View */}
<InventoryMobileList {...listProps} />
</div>
);
}
@@ -0,0 +1,91 @@
import { Activity, PlusCircle, Truck, Warehouse } from 'lucide-react';
import type { SummaryMetricsProps } from './types';
export function SummaryMetrics({
summary: SUMMARY,
operatingRate,
inventoryRate,
pendingRate,
setVehicleSelection: setShowPlateNumbers,
}: SummaryMetricsProps) {
return (
<>
{/* Header Summary - Ultra Compact */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">
{/* Total Assets */}
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
<Truck size={14} />
</div>
<div>
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5"></div>
<div className="text-base font-bold text-gray-800 leading-none">{SUMMARY.totalAssets.toLocaleString()}</div>
</div>
</div>
{/* Operating */}
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset', title: '正在运营' })}>
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
<Activity size={14} />
</div>
<div>
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5"></div>
<div className="flex items-baseline gap-1">
<span className="text-base font-bold text-blue-600 leading-none">{SUMMARY.operating.total}</span>
<span className="text-[8px] text-gray-400 leading-none">{SUMMARY.operating.self} {SUMMARY.operating.leased}{SUMMARY.operating.hanging > 0 && `${SUMMARY.operating.hanging}`}</span>
</div>
</div>
</div>
{/* Inventory */}
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
<Warehouse size={14} />
</div>
<div>
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5"></div>
<div className="flex items-baseline gap-1">
<span className="text-base font-bold text-gray-800 leading-none">{SUMMARY.inventory.total}</span>
<span className="text-[8px] text-gray-400 leading-none">{SUMMARY.inventory.inStock} {SUMMARY.inventory.abnormal}</span>
</div>
</div>
</div>
{/* Pending */}
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}>
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
<PlusCircle size={14} />
</div>
<div>
<div className="text-[9px] text-blue-500 font-bold uppercase leading-none mb-0.5"></div>
<div className="text-base font-bold text-blue-600 leading-none">{SUMMARY.pendingDelivery}</div>
</div>
</div>
</div>
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="shrink-0 text-[11px] font-black text-slate-400"></div>
<div className="grid min-w-0 flex-1 grid-cols-3 divide-x divide-slate-100 text-center">
<div className="px-2">
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
</div>
<div className="px-2">
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
</div>
<div className="px-2">
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 text-base font-black text-amber-600">{pendingRate.toFixed(1)}%</div>
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,71 @@
import type { Dispatch, SetStateAction } from 'react';
import type { FlowDetailItem, FlowStatsResponse, FlowType } from '../../api';
import type {
DateRange,
InventoryFilters,
VehicleModalSelection,
} from '../../model';
import type { RegionalInventoryStats, SummaryData, TypeSummary } from '../../types';
import type { AssetsTheme } from '../AssetsHeader';
export interface SummaryMetricsProps {
summary: SummaryData;
operatingRate: number;
inventoryRate: number;
pendingRate: number;
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
}
export interface FlowStatisticsCardProps {
flowRange: DateRange;
setFlowRange: Dispatch<SetStateAction<DateRange>>;
flowLoading: boolean;
flowStats: FlowStatsResponse | null;
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
flowDailyExpanded: boolean;
setFlowDailyExpanded: Dispatch<SetStateAction<boolean>>;
setSelectedFlow: Dispatch<SetStateAction<{ date: string; type: FlowType } | null>>;
}
export interface AssetSummarySectionProps {
processedData: TypeSummary[];
theme: AssetsTheme;
expandedAssetTypes: Set<string>;
toggleAssetType: (type: string) => void;
expandedModels: Set<string>;
toggleModel: (model: string) => void;
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
}
export interface InventoryStatisticsSectionProps {
inventoryFilters: InventoryFilters;
setInventoryFilters: Dispatch<SetStateAction<InventoryFilters>>;
draftInventoryFilters: InventoryFilters;
setDraftInventoryFilters: Dispatch<SetStateAction<InventoryFilters>>;
isInventoryFilterOpen: boolean;
setIsInventoryFilterOpen: Dispatch<SetStateAction<boolean>>;
uniqueInventoryRegions: string[];
uniqueInventoryCities: string[];
uniqueInventoryBrands: string[];
uniqueInventoryTypes: string[];
uniqueInventoryModelsForType: string[];
filteredInventoryStats: RegionalInventoryStats[];
inventoryTab: 'region' | 'model';
setInventoryTab: Dispatch<SetStateAction<'region' | 'model'>>;
inventoryByRegion: Record<string, Record<string, RegionalInventoryStats[]>>;
expandedInventoryRegions: Set<string>;
toggleInventoryRegion: (region: string) => void;
inventoryByModel: Record<string, Record<string, RegionalInventoryStats[]>>;
expandedInventoryTypes: Set<string>;
toggleInventoryType: (type: string) => void;
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
}
export type OverviewViewProps = SummaryMetricsProps
& FlowStatisticsCardProps
& AssetSummarySectionProps
& InventoryStatisticsSectionProps
& {
allTypesExpanded: boolean;
toggleAllAssetTypes: () => void;
};
+310
View File
@@ -0,0 +1,310 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { FlowStatsResponse, WeeklyDetailItem } from './api';
import type {
CustomerStats,
DeptGroup,
ManagerStats,
RegionalInventoryStats,
VehicleListItem,
} from './types';
import {
buildCustomerPieData,
buildVehicleModalRequest,
deriveCustomerView,
deriveDepartmentView,
deriveInventoryView,
deriveModalVehicleView,
formatLocalDate,
formatLocalDateTime,
getWeeklyFlowRange,
groupInventoryByModel,
selectFlowDetails,
} from './model';
const emptyInventoryFilters = { region: '', city: '', brand: '', type: '', model: '' };
const emptyCustomerFilters = { customer: [], brand: '', department: '', manager: '', region: '' };
const emptyModalFilters = { plateNumber: '', model: '', brand: '', location: '' };
function inventory(overrides: Partial<RegionalInventoryStats>): RegionalInventoryStats {
return {
region: '广东',
city: '广州',
brand: '现代',
type: '18T',
model: 'M1',
batch: 'B1',
quantity: 1,
...overrides,
};
}
function manager(overrides: Partial<ManagerStats>): ManagerStats {
return {
manager: '负责人甲',
department: '业务一部',
t4_5: 0,
t4_5c: 0,
t18: 0,
t49: 0,
trailer: 0,
other: 0,
total: 0,
...overrides,
};
}
function department(name: string, managers: ManagerStats[]): DeptGroup {
return {
department: name,
totalAssets: managers.reduce((sum, item) => sum + item.total, 0),
operatingCount: 0,
idleCount: 0,
attendanceRate: 0,
avgMileage: 0,
managers,
};
}
function customer(overrides: Partial<CustomerStats>): CustomerStats {
return {
customer: '客户甲',
manager: '负责人甲',
brand: '现代',
department: '业务一部',
region: '广东',
city: '广州',
t4_5: 0,
t4_5c: 0,
t18: 0,
t49: 0,
trailer: 0,
other: 0,
total: 1,
...overrides,
};
}
function vehicle(overrides: Partial<VehicleListItem>): VehicleListItem {
return {
id: 1,
plateNumber: '粤A00001',
vin: 'VIN-1',
type: '18T',
model: 'M1',
location: '广州',
province: '广东',
city: '广州',
status: '运营',
ownership: '自营',
contractNo: null,
customerName: null,
subjectOrg: null,
departmentName: null,
customerManager: null,
brandLabel: '现代',
orgName: null,
...overrides,
};
}
test('本地日期格式不引入 UTC 偏移', () => {
const date = new Date(2026, 7, 3, 9, 7, 5);
assert.equal(formatLocalDate(date), '2026-08-03');
assert.equal(formatLocalDateTime(date), '2026-08-03 09:07:05');
});
test('周流转区间按周六至周五计算', () => {
assert.deepEqual(getWeeklyFlowRange(new Date(2026, 7, 12)), {
start: '2026-08-08',
end: '2026-08-14',
});
assert.deepEqual(getWeeklyFlowRange(new Date(2026, 7, 15)), {
start: '2026-08-08',
end: '2026-08-14',
});
assert.deepEqual(getWeeklyFlowRange(new Date(2026, 7, 16)), {
start: '2026-08-08',
end: '2026-08-14',
});
});
test('交还替明细走周接口且原样保留周筛选参数', () => {
assert.deepEqual(
buildVehicleModalRequest({
batch: 'All',
model: 'M1',
location: '广州',
category: 'Delivered',
source: 'asset',
}, '主体甲'),
{
kind: 'weekly',
type: 'delivered',
filters: { model: 'M1', batch: 'All', location: '广州', source: 'asset' },
},
);
});
test('待交付与车型映射继续走车辆列表参数', () => {
assert.deepEqual(
buildVehicleModalRequest({
batch: 'B1',
model: 'M1',
location: '广州',
category: 'Pending',
type: '4.5T',
isColdChain: true,
isTrailer: false,
manager: '负责人甲',
}, '主体甲'),
{
kind: 'vehicles',
params: {
batch: 'B1',
model: 'M1',
location: '广州',
category: 'Pending',
manager: '负责人甲',
vehicleType: '4.5T冷链',
subject: '主体甲',
},
},
);
assert.deepEqual(
buildVehicleModalRequest({
batch: 'All',
model: 'All',
location: 'All',
isColdChain: false,
isTrailer: true,
}, null),
{
kind: 'vehicles',
params: { isColdChain: 'false', isTrailer: 'true', subject: null },
},
);
});
test('库存筛选与区域、车型分组保持既有顺序', () => {
const rows = [
inventory({ region: '广东', city: '广州', type: '18T', model: 'M1', quantity: 2 }),
inventory({ region: '浙江', city: '嘉兴', type: '4.5T普货', model: 'M2', quantity: 3 }),
inventory({ region: '广东', city: '佛山', type: '18T', model: 'M3', quantity: 4 }),
inventory({ region: '广东', city: '广州', type: '新增车型', model: 'M4', quantity: 5 }),
];
const view = deriveInventoryView(
rows,
{ ...emptyInventoryFilters, region: '广东', type: '18T' },
'18T',
);
assert.deepEqual(view.filtered.map((item) => item.model), ['M1', 'M3']);
assert.deepEqual(view.modelsForType, ['M1', 'M3']);
assert.deepEqual(Object.keys(view.byRegion), ['广东']);
assert.deepEqual(Object.keys(view.byRegion['广东']), ['广州', '佛山']);
assert.deepEqual(Object.keys(view.byModel), ['18T']);
assert.deepEqual(
Object.keys(groupInventoryByModel(rows)),
['4.5T普货', '18T', '新增车型'],
);
});
test('部门负责人去重、分组并按车辆数倒序', () => {
const departments = [
department('业务二部', [
manager({ manager: '负责人乙', department: '业务二部', total: 3 }),
manager({ manager: '负责人甲', department: '业务二部', total: 8 }),
]),
department('业务一部', [manager({ manager: '负责人甲', total: 5 })]),
];
const view = deriveDepartmentView(departments, 'All');
assert.deepEqual(view.managers, ['负责人乙', '负责人甲']);
assert.deepEqual(view.groupedManagers, [
{ department: '业务二部', managers: ['负责人乙', '负责人甲'] },
{ department: '业务一部', managers: ['负责人甲'] },
]);
assert.deepEqual(view.managerStats.map((item) => item.total), [8, 5, 3]);
});
test('客户筛选与负责人分组合并部门和客户来源', () => {
const departments = [
department('业务二部', [manager({ manager: '负责人乙', department: '业务二部' })]),
department('业务一部', [manager({ manager: '负责人甲' })]),
];
const customers = [
customer({ customer: '客户甲', department: '业务一部', manager: '负责人甲', total: 2 }),
customer({ customer: '客户乙', department: '业务二部', manager: '负责人丙', region: '浙江', city: '嘉兴', total: 3 }),
customer({ customer: '公务车', department: '公务车', manager: '负责人丁', total: 1 }),
];
const view = deriveCustomerView(
customers,
departments,
{ ...emptyCustomerFilters, customer: ['客户乙'], region: '浙江' },
);
assert.deepEqual(view.filtered.map((item) => item.customer), ['客户乙']);
assert.deepEqual(view.departments, ['业务一部', '业务二部', '公务车']);
assert.deepEqual(view.managersByDepartment, [
{ department: '业务一部', managers: ['负责人甲'] },
{ department: '业务二部', managers: ['负责人乙', '负责人丙'] },
{ department: '公务车', managers: ['负责人丁'] },
]);
});
test('弹窗筛选沿用车牌为空时回退 VIN 的规则', () => {
const vehicles = [
vehicle({ id: 1 }),
vehicle({ id: 2, plateNumber: '', vin: 'VIN-2', model: 'M2', brandLabel: '福田', location: '嘉兴' }),
];
const weeklyDetails: WeeklyDetailItem[] = [
{ truck_id: 1, plate_number: '粤A00001', handover_date: null, contract_type: null, customer_name: null },
{ truck_id: 2, plate_number: 'VIN-2', handover_date: null, contract_type: null, customer_name: null },
];
const view = deriveModalVehicleView(
vehicles,
weeklyDetails,
{ ...emptyModalFilters, plateNumber: 'VIN-2' },
);
assert.deepEqual(view.plates, ['粤A00001', 'VIN-2']);
assert.deepEqual(view.filteredVehicles.map((item) => item.id), [2]);
assert.deepEqual(view.filteredWeeklyDetails.map((item) => item.truck_id), [2]);
});
test('流转明细按日期和类型同时筛选,客户饼图按区域汇总', () => {
const flowStats: FlowStatsResponse = {
start: '2026-08-08',
end: '2026-08-14',
daily: [],
totals: { delivered: 1, returned: 1, replaced: 0, total: 2 },
details: [
{
id: '1', type: 'delivered', typeLabel: '交车', date: '2026-08-12', truckId: '1',
plateNumber: '粤A00001', eventTime: null, submitTime: null, department: '', manager: '', customerName: null,
},
{
id: '2', type: 'returned', typeLabel: '还车', date: '2026-08-12', truckId: '2',
plateNumber: '粤A00002', eventTime: null, submitTime: null, department: '', manager: '', customerName: null,
},
],
};
assert.deepEqual(
selectFlowDetails(flowStats, { date: '2026-08-12', type: 'delivered' }).map((item) => item.id),
['1'],
);
const customers = [
customer({ region: '广东', total: 2 }),
customer({ customer: '客户乙', region: '浙江', total: 5 }),
customer({ customer: '客户丙', region: '广东', total: 3 }),
];
assert.deepEqual(buildCustomerPieData(customers, 'region', []), [
{ name: '广东', value: 5 },
{ name: '浙江', value: 5 },
]);
const provinceData = [{ name: '广东省', value: 10 }];
assert.equal(buildCustomerPieData(customers, 'province', provinceData), provinceData);
});
+412
View File
@@ -0,0 +1,412 @@
import type { FlowStatsResponse, FlowType, WeeklyDetailItem } from './api';
import type {
CustomerStats,
DeptGroup,
RegionalInventoryStats,
VehicleListItem,
} from './types';
const INVENTORY_TYPE_ORDER = ['4.5T普货', '4.5T冷链', '18T', '49T', '挂车', '其他'];
const CHINESE_NUMBER_ORDER: Record<string, number> = {
: 1,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9,
: 10,
};
export interface DateRange {
start: string;
end: string;
}
export interface InventoryFilters {
region: string;
city: string;
brand: string;
type: string;
model: string;
}
export interface CustomerFilters {
customer: string[];
brand: string;
department: string;
manager: string;
region: string;
}
export interface ModalVehicleFilters {
plateNumber: string;
model: string;
brand: string;
location: string;
}
export type VehicleModalCategory =
| 'Inventory'
| 'Pending'
| 'Delivered'
| 'Returned'
| 'Replaced'
| 'Operating';
export interface VehicleModalSelection {
batch: string;
model: string;
location: string;
category?: VehicleModalCategory;
vehicleType?: string;
manager?: string;
customer?: string;
department?: string;
attendance?: 'active' | 'idle';
isColdChain?: boolean;
isTrailer?: boolean;
type?: string;
source?: string;
title?: string;
}
export interface VehicleListRequestParams {
batch?: string;
model?: string;
location?: string;
category?: 'Inventory' | 'Operating' | 'Pending';
vehicleType?: string;
manager?: string;
customer?: string;
isColdChain?: string;
isTrailer?: string;
department?: string;
attendance?: string;
subject?: string | null;
source?: string;
}
export type VehicleModalRequest =
| {
kind: 'weekly';
type: FlowType;
filters: { model: string; batch: string; location: string; source?: string };
}
| { kind: 'vehicles'; params: VehicleListRequestParams };
export interface FlowSelection {
date: string;
type: FlowType;
}
export function formatLocalDateTime(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
export function formatLocalDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function addDays(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
// 资产流转周报按周六至周五统计;周末查看时仍停留在刚结束的周五。
export function getWeeklyFlowRange(referenceDate = new Date()): DateRange {
const day = referenceDate.getDay();
const end = day === 6
? addDays(referenceDate, -1)
: day === 0
? addDays(referenceDate, -2)
: addDays(referenceDate, 5 - day);
return {
start: formatLocalDate(addDays(end, -6)),
end: formatLocalDate(end),
};
}
const WEEKLY_FLOW_TYPE_BY_CATEGORY: Partial<Record<VehicleModalCategory, FlowType>> = {
Delivered: 'delivered',
Returned: 'returned',
Replaced: 'replaced',
};
// Pending 不是周流转事件,必须继续走车辆列表接口以保留型号、批次和区域筛选。
export function buildVehicleModalRequest(
selection: VehicleModalSelection,
subject: string | null,
): VehicleModalRequest {
const weeklyType = selection.category
? WEEKLY_FLOW_TYPE_BY_CATEGORY[selection.category]
: undefined;
if (weeklyType) {
return {
kind: 'weekly',
type: weeklyType,
filters: {
model: selection.model,
batch: selection.batch,
location: selection.location,
source: selection.source,
},
};
}
const params: VehicleListRequestParams = {};
if (selection.vehicleType) params.vehicleType = selection.vehicleType;
if (selection.batch !== 'All') params.batch = selection.batch;
if (selection.model !== 'All') params.model = selection.model;
if (selection.location !== 'All') params.location = selection.location;
if (selection.source) params.source = selection.source;
if (selection.category === 'Inventory') params.category = 'Inventory';
if (selection.category === 'Operating') params.category = 'Operating';
if (selection.category === 'Pending') params.category = 'Pending';
if (selection.manager) params.manager = selection.manager;
if (selection.customer) params.customer = selection.customer;
if (selection.department) params.department = selection.department;
if (selection.attendance) params.attendance = selection.attendance;
if (!selection.type) {
if (selection.isColdChain !== undefined) params.isColdChain = String(selection.isColdChain);
if (selection.isTrailer !== undefined) params.isTrailer = String(selection.isTrailer);
}
// 页面车型分组与列表接口的 vehicleType 取值并不完全一致,这里集中保留既有映射。
if (selection.type === '4.5T') {
if (selection.isColdChain === true) params.vehicleType = '4.5T冷链';
if (selection.isColdChain === false) params.vehicleType = '4.5T普货';
} else if (
selection.type === '4.5T普货'
|| selection.type === '4.5T冷链'
|| selection.type === '18T'
|| selection.type === '49T'
|| selection.type === '挂车'
|| selection.type === '其他'
) {
params.vehicleType = selection.type;
} else if (selection.type === '其他车型') {
if (selection.isTrailer === true) params.isTrailer = 'true';
if (selection.isTrailer === false) params.vehicleType = '其他';
}
return { kind: 'vehicles', params: { ...params, subject } };
}
function uniqueNonEmpty(values: Array<string | null | undefined>): string[] {
return Array.from(new Set(values.filter((value): value is string => Boolean(value))));
}
function getDepartmentOrder(name: string): number {
const match = name.match(/[一二三四五六七八九十]/);
return match ? (CHINESE_NUMBER_ORDER[match[0]] || 99) : 99;
}
export function filterInventoryStats(
inventory: RegionalInventoryStats[],
filters: InventoryFilters,
): RegionalInventoryStats[] {
return inventory.filter((item) => (
(!filters.region || item.region === filters.region)
&& (!filters.city || item.city === filters.city)
&& (!filters.brand || item.brand === filters.brand)
&& (!filters.type || item.type === filters.type)
&& (!filters.model || item.model === filters.model)
));
}
export function groupInventoryByRegion(
inventory: RegionalInventoryStats[],
): Record<string, Record<string, RegionalInventoryStats[]>> {
const result: Record<string, Record<string, RegionalInventoryStats[]>> = {};
for (const item of inventory) {
if (!result[item.region]) result[item.region] = {};
if (!result[item.region][item.city]) result[item.region][item.city] = [];
result[item.region][item.city].push(item);
}
return result;
}
export function groupInventoryByModel(
inventory: RegionalInventoryStats[],
): Record<string, Record<string, RegionalInventoryStats[]>> {
const raw: Record<string, Record<string, RegionalInventoryStats[]>> = {};
for (const item of inventory) {
if (!raw[item.type]) raw[item.type] = {};
if (!raw[item.type][item.model]) raw[item.type][item.model] = [];
raw[item.type][item.model].push(item);
}
// 已知车型按报表约定排序,新增的未知车型保留接口返回时的首次出现顺序。
const result: Record<string, Record<string, RegionalInventoryStats[]>> = {};
for (const type of INVENTORY_TYPE_ORDER) {
if (raw[type]) result[type] = raw[type];
}
for (const type of Object.keys(raw)) {
if (!result[type]) result[type] = raw[type];
}
return result;
}
export function deriveInventoryView(
inventory: RegionalInventoryStats[],
filters: InventoryFilters,
modelTypeFilter: string,
) {
const filtered = filterInventoryStats(inventory, filters);
const modelSource = modelTypeFilter
? inventory.filter((item) => item.type === modelTypeFilter)
: inventory;
const types = uniqueNonEmpty(inventory.map((item) => item.type));
return {
filtered,
brands: uniqueNonEmpty(inventory.map((item) => item.brand)),
regions: Array.from(new Set(inventory.map((item) => item.region))),
cities: uniqueNonEmpty(inventory.map((item) => item.city)),
types: types.sort(
(left, right) => INVENTORY_TYPE_ORDER.indexOf(left) - INVENTORY_TYPE_ORDER.indexOf(right),
),
modelsForType: uniqueNonEmpty(modelSource.map((item) => item.model)),
byRegion: groupInventoryByRegion(filtered),
byModel: groupInventoryByModel(filtered),
};
}
export function deriveDepartmentView(departments: DeptGroup[], selectedManager: string) {
const managers = departments
.flatMap((department) => department.managers.map((manager) => manager.manager))
.filter((value, index, all) => all.indexOf(value) === index)
.sort();
const groupedManagers = departments.map((department) => ({
department: department.department,
managers: department.managers.map((manager) => manager.manager),
}));
const managerStats = departments
.flatMap((department) => department.managers)
.filter((manager) => selectedManager === 'All' || manager.manager === selectedManager)
.sort((left, right) => right.total - left.total);
return { managers, groupedManagers, managerStats };
}
export function filterCustomerStats(
customers: CustomerStats[],
filters: CustomerFilters,
): CustomerStats[] {
return customers.filter((customer) => (
(filters.customer.length === 0 || filters.customer.includes(customer.customer))
&& (!filters.brand || customer.brand === filters.brand)
&& (!filters.department || customer.department === filters.department)
&& (!filters.manager || customer.manager === filters.manager)
&& (!filters.region || customer.region === filters.region)
));
}
function groupCustomerManagers(
customers: CustomerStats[],
departments: DeptGroup[],
): Array<{ department: string; managers: string[] }> {
const departmentManagers = new Map<string, Set<string>>();
for (const department of departments) {
if (!departmentManagers.has(department.department)) {
departmentManagers.set(department.department, new Set());
}
for (const manager of department.managers) {
departmentManagers.get(department.department)!.add(manager.manager);
}
}
for (const customer of customers) {
if (!customer.manager || !customer.department) continue;
if (!departmentManagers.has(customer.department)) {
departmentManagers.set(customer.department, new Set());
}
departmentManagers.get(customer.department)!.add(customer.manager);
}
return Array.from(departmentManagers.entries())
.sort((left, right) => {
const leftOrder = left[0] === '公务车' ? 100 : getDepartmentOrder(left[0]);
const rightOrder = right[0] === '公务车' ? 100 : getDepartmentOrder(right[0]);
return leftOrder - rightOrder;
})
.map(([department, managers]) => ({ department, managers: Array.from(managers) }));
}
export function deriveCustomerView(
customers: CustomerStats[],
departments: DeptGroup[],
filters: CustomerFilters,
) {
return {
filtered: filterCustomerStats(customers, filters),
brands: uniqueNonEmpty(customers.map((customer) => customer.brand)),
departments: uniqueNonEmpty(customers.map((customer) => customer.department))
.sort((left, right) => getDepartmentOrder(left) - getDepartmentOrder(right)),
regions: Array.from(new Set(customers.map((customer) => customer.region))),
cities: uniqueNonEmpty(customers.map((customer) => customer.city)),
customerNames: uniqueNonEmpty(customers.map((customer) => customer.customer)),
managersByDepartment: groupCustomerManagers(customers, departments),
};
}
export function deriveModalVehicleView(
vehicles: VehicleListItem[],
weeklyDetails: WeeklyDetailItem[],
filters: ModalVehicleFilters,
) {
return {
plates: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.plateNumber || vehicle.vin)),
models: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.model)),
brands: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.brandLabel)),
locations: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.location)),
filteredVehicles: vehicles.filter((vehicle) => (
(!filters.plateNumber || (vehicle.plateNumber || vehicle.vin) === filters.plateNumber)
&& (!filters.model || vehicle.model === filters.model)
&& (!filters.brand || vehicle.brandLabel === filters.brand)
&& (!filters.location || vehicle.location === filters.location)
)),
filteredWeeklyDetails: weeklyDetails.filter((detail) => (
!filters.plateNumber || detail.plate_number === filters.plateNumber
)),
};
}
export function selectFlowDetails(
flowStats: FlowStatsResponse | null,
selection: FlowSelection | null,
) {
if (!flowStats || !selection) return [];
return flowStats.details.filter((detail) => (
detail.date === selection.date && detail.type === selection.type
));
}
export function buildCustomerPieData(
customers: CustomerStats[],
view: 'region' | 'province',
provinceData: Array<{ name: string; value: number }>,
): Array<{ name: string; value: number }> {
if (view === 'province') return provinceData;
const totals: Record<string, number> = {};
for (const customer of customers) {
totals[customer.region] = (totals[customer.region] || 0) + customer.total;
}
return Object.entries(totals)
.map(([name, value]) => ({ name, value }))
.sort((left, right) => right.value - left.value);
}