feat(assets): migrate asset views and reconcile inventory drilldowns

This commit is contained in:
shishengliang
2026-09-23 14:16:22 +08:00
parent b0cba26fbb
commit 822aabf2ca
77 changed files with 14669 additions and 3856 deletions
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -37,6 +37,7 @@ export async function fetchByType(subject?: string | null): Promise<TypeSummary[
}
export async function fetchVehicleList(params: {
brand?: string;
batch?: string;
model?: string;
location?: string;
@@ -53,6 +54,7 @@ export async function fetchVehicleList(params: {
source?: string;
}): Promise<VehicleListItem[]> {
const query = new URLSearchParams();
if (params.brand) query.set('brand', params.brand);
if (params.batch) query.set('batch', params.batch);
if (params.model) query.set('model', params.model);
if (params.location) query.set('location', params.location);
@@ -153,13 +155,14 @@ export async function fetchRegionChart(
export async function fetchWeeklyDetail(
type: string,
filters?: { model?: string; batch?: string; location?: string; source?: string },
filters?: { model?: string; batch?: string; location?: string; source?: string; subject?: string | null },
): Promise<WeeklyDetailItem[]> {
const params = new URLSearchParams({ type });
if (filters?.model && filters.model !== 'All') params.set('model', filters.model);
if (filters?.batch && filters.batch !== 'All') params.set('batch', filters.batch);
if (filters?.location && filters.location !== 'All') params.set('location', filters.location);
if (filters?.source) params.set('source', filters.source);
if (filters?.subject) params.set('subject', filters.subject);
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
}
-25
View File
@@ -1,25 +0,0 @@
/* Scoped readability and interaction rules for the asset workspace. */
.asset-workspace { font-variant-numeric: tabular-nums; }
.asset-workspace :is(button, input, select, [tabindex]):focus-visible {
outline: 2px solid #2563eb;
outline-offset: 3px;
}
.asset-workspace :is(input, select) { min-height: 38px; }
.asset-workspace [class~="text-[8px]"],
.asset-workspace [class~="text-[9px]"],
.asset-workspace [class~="text-[10px]"] { font-size: 11px; line-height: 1.5; }
.asset-workspace :is(.text-gray-400, .text-slate-400) { color: #64748b; }
.asset-workspace table { font-variant-numeric: tabular-nums; }
.asset-workspace table th { white-space: nowrap; }
.asset-workspace table td { line-height: 1.6; }
.asset-workspace .asset-table-scroll { max-height: 65vh; overflow: auto; overscroll-behavior: contain; }
.asset-workspace .asset-table-scroll thead { position: sticky; top: 0; z-index: 10; background: #f8fafc; }
@media (max-width: 767px) {
.asset-workspace { padding-bottom: 28px; }
.asset-workspace :is(input,select) { min-height: 42px; font-size: 16px; }
.asset-workspace .asset-header { padding-top: 12px; }
.asset-workspace .asset-filter-panel { position: fixed; inset: auto 8px 12px; max-height: 80dvh; overflow-y: auto; width: auto; border-radius: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.asset-workspace * { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
@@ -1,52 +0,0 @@
import Blur from '../../../components/Blur';
import type { VehicleListItem } from '../types';
type DetailsProps = { vehicles: VehicleListItem[] };
function InventoryVehicleDetails({ vehicles, kind }: DetailsProps & { kind: 'abnormal' | 'pending' }) {
const pending = kind === 'pending';
const labels = pending ? ['合同编号', '客户名称(已脱敏)'] : ['异动类型', '预计结束时间'];
const details = (vehicle: VehicleListItem) => pending
? [vehicle.pendingDelivery?.contractNo || '未提供', vehicle.pendingDelivery?.customerName || '未提供']
: [vehicle.abnormalMove?.type || '未提供', vehicle.abnormalMove?.targetCompletionTime || '未提供'];
if (vehicles.length === 0) return <div className="py-14 text-center text-sm text-slate-500">暂无符合筛选条件的{pending ? '待交车' : '异动车辆'}</div>;
return <>
<p className="px-4 py-3 text-xs leading-5 text-slate-500">{pending ? '展示当前待交车任务的合同信息,客户名称已脱敏。' : '展示当前异动类型与预计结束时间。'}</p>
<div className="space-y-3 px-3 pb-3 md:hidden">
{vehicles.map(vehicle => <article key={vehicle.id} className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<h4 className="break-all font-mono text-base font-bold text-blue-700"><Blur>{vehicle.plateNumber || vehicle.vin || '—'}</Blur></h4>
<p className="mt-1 text-xs text-slate-500">{vehicle.brandLabel || '—'} · {vehicle.type} · {vehicle.location === '其他' ? '对接中' : vehicle.location}</p>
<dl className="mt-4 space-y-3 text-xs">
{details(vehicle).map((value, index) => <div key={labels[index]} className="grid grid-cols-[7.5rem_minmax(0,1fr)] gap-2">
<dt className="text-slate-500">{labels[index]}</dt>
<dd className="break-words font-medium text-slate-800 [overflow-wrap:anywhere]"><Blur>{value}</Blur></dd>
</div>)}
</dl>
</article>)}
</div>
<div className="hidden md:block">
<table className="w-full min-w-[700px] border-collapse text-left text-xs">
<thead className="sticky top-0 z-20 bg-slate-700 text-white"><tr>
{['车牌', '品牌', '车型', '所在地', ...labels].map(label => <th key={label} className="whitespace-nowrap p-3">{label}</th>)}
</tr></thead>
<tbody>{vehicles.map(vehicle => <tr key={vehicle.id} className="border-b border-slate-200 bg-white align-top">
<td className="p-3 font-mono font-bold text-blue-700"><Blur>{vehicle.plateNumber || vehicle.vin || '—'}</Blur></td>
<td className="p-3 text-slate-600">{vehicle.brandLabel || '—'}</td>
<td className="p-3 text-slate-600">{vehicle.type}</td>
<td className="p-3 text-slate-600">{vehicle.location === '其他' ? '对接中' : vehicle.location}</td>
{details(vehicle).map((value, index) => <td key={labels[index]} className="max-w-64 break-words p-3 text-slate-700 [overflow-wrap:anywhere]"><Blur>{value}</Blur></td>)}
</tr>)}</tbody>
</table>
</div>
</>;
}
export function AbnormalVehicleDetails(props: DetailsProps) {
return <InventoryVehicleDetails {...props} kind="abnormal" />;
}
export function PendingVehicleDetails(props: DetailsProps) {
return <InventoryVehicleDetails {...props} kind="pending" />;
}
@@ -1,63 +0,0 @@
import React from 'react';
import { Building2, ChevronDown, RefreshCw, Search, X } from 'lucide-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;
onRefresh: () => void;
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>;
}
const tabs: {id: AssetsTab; label: string}[] = [
{id:'overview',label:'总览'}, {id:'department',label:'按部门'},
{id:'region',label:'按区域'}, {id:'customer',label:'按客户'},
];
export function AssetsHeader(props: AssetsHeaderProps) {
const {activeTab,setActiveTab,selectedSubject,setSelectedSubject,subjectDropdownOpen,setSubjectDropdownOpen} = props;
const chooseSubject = (subject: string | null) => {setSelectedSubject(subject);setSubjectDropdownOpen(false);};
return <header className="asset-header sticky top-0 z-40 -mx-3 -mt-3 mb-5 border-b border-slate-200 bg-white/95 px-4 pt-4 backdrop-blur-md md:-mx-6 md:-mt-6 md:px-6">
<div className="flex items-center justify-between gap-3">
<div><h1 className="text-xl font-bold tracking-tight text-slate-900">资产管理</h1><p className="mt-1 hidden text-xs text-slate-500 sm:block">运营、库存与交还车,一处查看</p></div>
<div className="flex items-center gap-2">
<select aria-label="界面风格" value={props.theme} onChange={e=>props.setTheme(e.target.value as AssetsTheme)} className="hidden rounded-lg border border-slate-200 bg-white px-2 py-2 text-xs text-slate-600 md:block">
<option value="soft">柔和</option><option value="minimal">简约</option><option value="vibrant">经典</option>
</select>
<button type="button" onClick={props.onRefresh} disabled={props.loading} className="flex min-h-10 items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 text-xs font-semibold text-slate-700 hover:bg-slate-50 disabled:opacity-60"><RefreshCw size={15} className={props.loading?'animate-spin':''}/>{props.loading?'更新中':'刷新'}</button>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<div className="relative flex min-w-0 items-center gap-1" ref={props.subjectDropdownRef}>
<button type="button" aria-expanded={subjectDropdownOpen} aria-controls="asset-company-options" onClick={()=>{setSubjectDropdownOpen(v=>!v);props.setSubjectSearch('');}} className="flex min-h-10 max-w-full items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 px-3 text-xs text-slate-700">
<Building2 size={15}/><span className="max-w-[220px] truncate">{selectedSubject || '全部公司'}</span><ChevronDown size={14}/>
</button>
{selectedSubject && <button type="button" aria-label="清除公司筛选" onClick={()=>chooseSubject(null)} className="rounded-lg p-2 text-slate-500 hover:bg-slate-100"><X size={16}/></button>}
{subjectDropdownOpen && <div id="asset-company-options" className="absolute left-0 top-full z-50 mt-2 w-[min(360px,calc(100vw-48px))] rounded-xl border border-slate-200 bg-white p-2 shadow-xl" onKeyDown={e=>{if(e.key==='Escape'){setSubjectDropdownOpen(false);e.stopPropagation();}}}>
<label className="flex items-center gap-2 rounded-lg border border-slate-200 px-3"><Search size={15} className="text-slate-500"/><input autoFocus aria-label="搜索公司" value={props.subjectSearch} onChange={e=>props.setSubjectSearch(e.target.value)} placeholder="搜索公司名称" className="min-h-10 min-w-0 flex-1 text-sm outline-none"/></label>
<div className="mt-2 max-h-64 overflow-auto">
<button type="button" onClick={()=>chooseSubject(null)} className="w-full rounded-lg px-3 py-3 text-left text-sm hover:bg-blue-50">全部公司</button>
{props.subjects.filter(s=>s.name.includes(props.subjectSearch)).map(s=><button key={s.name} type="button" onClick={()=>chooseSubject(s.name)} className={`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-3 text-left text-xs hover:bg-blue-50 ${selectedSubject===s.name?'bg-blue-50 text-blue-700':'text-slate-700'}`}><span className="min-w-0 break-words">{s.name}</span><span className="shrink-0 tabular-nums text-slate-500">{s.total} 台</span></button>)}
{props.subjects.filter(s=>s.name.includes(props.subjectSearch)).length===0 && <p className="p-4 text-center text-xs text-slate-500">没有匹配的公司</p>}
</div>
</div>}
</div>
<p className="text-[11px] text-slate-500" title="运维台账每日统计口径:当日17:00">更新于 {props.lastUpdate.slice(11,16)}<span className="hidden sm:inline"> · 运维台账截至当日 17:00</span></p>
</div>
<nav aria-label="资产视图" className="mt-3 grid grid-cols-4 gap-1 border-t border-slate-100 pt-2">
{tabs.map(tab=><button type="button" key={tab.id} aria-pressed={activeTab===tab.id} onClick={()=>setActiveTab(tab.id)} className={`min-h-11 rounded-t-lg border-b-2 px-2 py-3 text-sm transition-colors ${activeTab===tab.id?'border-blue-600 bg-blue-50/70 font-bold text-blue-700':'border-transparent text-slate-500 hover:bg-slate-50 hover:text-slate-800'}`}>{tab.label}</button>)}
</nav>
</header>;
}
@@ -1,154 +0,0 @@
import React, { useRef, useCallback } from 'react';
import { useDialogFocus } from './useDialogFocus';
import { Download, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { FlowDetailItem, FlowType } from '../api';
import type { FlowSelection } from '../model';
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: FlowSelection | null;
selectedFlowDetails: FlowDetailItem[];
setSelectedFlow: React.Dispatch<React.SetStateAction<FlowSelection | null>>;
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
}
export function FlowDetailModal({
selectedFlow,
selectedFlowDetails,
setSelectedFlow,
exportFlowDetails,
}: FlowDetailModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const close = useCallback(()=>setSelectedFlow(null),[setSelectedFlow]);
useDialogFocus(Boolean(selectedFlow),dialogRef,close);
const dateLabel = selectedFlow
? ('date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`)
: '';
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
ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="asset-flow-title" tabIndex={-1}
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 id="asset-flow-title" className="mt-1 text-lg font-black">
{dateLabel} · {FLOW_META[selectedFlow.type].label}明细
</h3>
<div className="mt-1 text-[12px] font-bold text-slate-300">
共 {selectedFlowDetails.length} 车次,包含车牌、运维签章时间、建单时间、部门、负责人和客户
</div>
</div>
<button
type="button"
aria-label="关闭流转明细"
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, `${dateLabel}-${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">运维签章时间</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}{item.type === 'replaced' && <span className="mt-1 block text-[10px] text-violet-600">替换交车</span>}</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">运维签章时间</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>
</>
);
}
@@ -1,76 +0,0 @@
import { useState } from 'react';
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) {
const [panel, setPanel] = useState<'inventory' | 'models' | 'flow'>('inventory');
return (
<div className="space-y-3">
<SummaryMetrics
summary={props.summary}
operatingRate={props.operatingRate}
inventoryRate={props.inventoryRate}
pendingRate={props.pendingRate}
setVehicleSelection={props.setVehicleSelection}
/>
<nav aria-label="总览统计视图" className="grid grid-cols-3 gap-1 rounded-xl border border-slate-200 bg-slate-100 p-1">
{([{id:'inventory',label:'库存统计'},{id:'models',label:'车型资产'},{id:'flow',label:'交还车统计'}] as const).map(item => <button key={item.id} type="button" aria-pressed={panel===item.id} onClick={()=>setPanel(item.id)} className={`min-h-11 rounded-lg px-2 py-2 text-xs sm:text-sm font-semibold transition-colors ${panel===item.id?'bg-white text-blue-700 shadow-sm':'text-slate-600 hover:bg-white/60'}`}>{item.label}</button>)}
</nav>
<div hidden={panel !== 'flow'}>
<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}
/>
</div>
<div hidden={panel !== 'models'}>
<AssetSummarySection
processedData={props.processedData}
theme={props.theme}
expandedAssetTypes={props.expandedAssetTypes}
toggleAssetType={props.toggleAssetType}
expandedModels={props.expandedModels}
toggleModel={props.toggleModel}
setVehicleSelection={props.setVehicleSelection}
/>
</div>
<div hidden={panel !== 'inventory'}>
<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}
/>
</div>
</div>
);
}
@@ -1,302 +0,0 @@
import React, { useRef, useCallback } from 'react';
import { useDialogFocus } from './useDialogFocus';
import { VehicleMobileCards } from './VehicleMobileCards';
import { AbnormalVehicleDetails, PendingVehicleDetails } from './AbnormalVehicleDetails';
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;
error?: string | null;
onRetry?: () => void;
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,
error,
onRetry,
weeklyDetails: modalWeeklyDetail,
filteredWeeklyDetails: filteredModalWeeklyDetail,
filteredVehicles: filteredModalVehicles,
}: VehicleDetailModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => setShowPlateNumbers(null), [setShowPlateNumbers]);
useDialogFocus(Boolean(showPlateNumbers), dialogRef, close);
return (
<>
{/* Vehicle Detail Modal */}
<AnimatePresence>
{showPlateNumbers && (
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[80] flex items-center justify-center p-4">
<motion.div
ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="asset-vehicle-title" tabIndex={-1}
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-[90dvh] 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 id="asset-vehicle-title" 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 === 'Abnormal' ? '异动' :
showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location === 'All' ? '总' : showPlateNumbers.location}库存` :
showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}
</p>
</div>
<button aria-label="关闭车辆明细" onClick={close} 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="搜索车牌 / VIN" 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">
{error ? (
<div role="alert" className="p-10 text-center text-sm text-slate-600"><p>{error}</p><button type="button" onClick={onRetry} className="mt-4 rounded-xl bg-blue-600 px-5 py-2 text-white">重新加载</button></div>
) : modalLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="animate-spin text-blue-500" size={32} />
</div>
) : showPlateNumbers.category === 'Abnormal' ? (
<AbnormalVehicleDetails vehicles={filteredModalVehicles} />
) : showPlateNumbers.category === 'Pending' ? (
<PendingVehicleDetails vehicles={filteredModalVehicles} />
) : 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>
) : (
<>
<VehicleMobileCards vehicles={filteredModalVehicles} customer={showPlateNumbers.source === 'customer'} />
<div className="hidden md:block 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-[11px] 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">{['Delivered','Returned','Replaced'].includes(showPlateNumbers.category || '') ? 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>
</>
);
}
@@ -1,15 +0,0 @@
import Blur from '../../../components/Blur';
import type { VehicleListItem } from '../types';
export function VehicleMobileCards({vehicles, customer}: {vehicles: VehicleListItem[];customer: boolean}) {
return <div className="space-y-3 p-3 md:hidden">
{vehicles.length===0 && <p className="py-12 text-center text-sm text-slate-500">暂无符合条件的车辆,试试调整筛选条件。</p>}
{vehicles.map(vehicle=><article key={vehicle.id} className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-start justify-between gap-2"><h4 className="break-all font-mono text-base font-bold text-blue-700"><Blur>{vehicle.plateNumber || vehicle.vin || '未提供车牌'}</Blur></h4><span className="shrink-0 rounded-md bg-slate-100 px-2 py-1 text-xs text-slate-600">{({Operating:'运营',Inventory:'库存',Pending:'待交车',Abnormal:'异动'} as Record<string,string>)[vehicle.status] || '未提供'}</span></div>
<p className="mt-2 text-xs text-slate-500">{vehicle.brandLabel || '—'} · {vehicle.type} · {vehicle.location==='其他'?'对接中':vehicle.location}</p>
<dl className="mt-3 space-y-2 border-t border-slate-100 pt-3 text-xs">
{(customer ? [['客户',vehicle.customerName],['合同编号',vehicle.contractNo],['业务部门',vehicle.departmentName],['业务负责人',vehicle.customerManager],['资产归属',vehicle.subjectOrg]] : [['型号',vehicle.model],['客户',vehicle.customerName]]).map(([label,value])=><div key={label} className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-2"><dt className="text-slate-500">{label}</dt><dd className="break-words text-slate-800 [overflow-wrap:anywhere]"><Blur>{value || '未提供'}</Blur></dd></div>)}
</dl>
</article>)}
</div>;
}
@@ -1,203 +0,0 @@
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 asset-table-scroll overflow-x-auto">
<table className="w-full text-left border-collapse table-fixed min-w-[1300px]">
<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 text-amber-600 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'
}`}
tabIndex={0} onKeyDown={e=>{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleAssetType(typeGroup.type);}}} onClick={() => toggleAssetType(typeGroup.type)}>
<td colSpan={15} 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="font-bold">{typeGroup.totalAbnormal}</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' : ''}`}
tabIndex={0} onKeyDown={e=>{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleModel(model.model);}}} 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-[11px] 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">
<button type="button" className="font-bold text-amber-600 hover:underline"
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Abnormal', source: 'asset', title: `${model.model} - 异动` }); }}>
{model.abnormal}
</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>
);
}
@@ -1,129 +0,0 @@
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'
}`}
tabIndex={0} onKeyDown={e=>{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleAssetType(typeGroup.type);}}} 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-[11px] 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="font-bold">{typeGroup.totalAbnormal}</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"
tabIndex={0} onKeyDown={e=>{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleModel(model.model);}}} 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 flex-wrap justify-end 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>
<button type="button" className="text-[10px] bg-amber-50 text-amber-700 px-1.5 py-0.5 rounded font-bold" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Abnormal', source: 'asset', title: `${model.model} - 异动` }); }}>其中异动 {model.abnormal}</button>
<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-[11px] 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-[11px] 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-[11px] 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-[11px] 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>
);
}
@@ -1,26 +0,0 @@
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>
);
}
@@ -1,118 +0,0 @@
import { useRef, useState } from 'react';
import { ArrowRight, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { getRecentFlowRange, getWeeklyFlowRange, type DateRange } from '../../model';
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
const ymd = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const parseDate = (value: string) => new Date(`${value}T12:00:00`);
const shiftDay = (date: Date, offset: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset);
const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1;
export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onChange: (range: DateRange) => void }) {
const dialog = useRef<HTMLDialogElement>(null);
const [draft, setDraft] = useState(value);
const [selecting, setSelecting] = useState<'start' | 'end'>('start');
const [month, setMonth] = useState(() => parseDate(value.start));
const [hovered, setHovered] = useState('');
const today = ymd(new Date());
const count = draft.end ? dayCount(draft.start, draft.end) : 0;
const valid = count > 0 && count <= 370;
const monthStart = new Date(month.getFullYear(), month.getMonth(), 1);
const previewEnd = draft.end || hovered || draft.start;
const rangeStart = draft.start < previewEnd ? draft.start : previewEnd;
const rangeEnd = draft.start > previewEnd ? draft.start : previewEnd;
const quickPicks = [
{ label: '今天', range: { start: today, end: today } },
{ label: '昨天', range: { start: ymd(shiftDay(new Date(), -1)), end: ymd(shiftDay(new Date(), -1)) } },
{ label: '业务周', range: getWeeklyFlowRange() },
{ label: '近 7 天', range: getRecentFlowRange() },
{ label: '近 30 天', range: { start: ymd(shiftDay(new Date(), -29)), end: today } },
];
const open = () => {
setDraft(value); setSelecting('start'); setMonth(parseDate(value.start)); setHovered('');
dialog.current?.showModal();
};
const choose = (date: string) => {
if (selecting === 'start') {
setDraft({ start: date, end: '' }); setSelecting('end');
} else {
setDraft(date < draft.start ? { start: date, end: draft.start } : { start: draft.start, end: date });
setSelecting('start');
}
setHovered('');
};
const calendar = (offset: number) => {
const current = new Date(monthStart.getFullYear(), monthStart.getMonth() + offset, 1);
const year = current.getFullYear(), m = current.getMonth();
const padding = (current.getDay() + 6) % 7;
const length = new Date(year, m + 1, 0).getDate();
return <div className={offset ? 'hidden sm:block' : ''} key={offset}>
<div className="mb-3 text-center text-sm font-bold text-slate-700">{year} 年 {m + 1} 月</div>
<div className="grid grid-cols-7 text-center">
{weekdays.map(day => <div key={day} className="py-2 text-[11px] font-medium text-slate-400">{day}</div>)}
{Array.from({ length: padding }, (_, i) => <div key={`blank-${i}`} />)}
{Array.from({ length }, (_, i) => {
const date = ymd(new Date(year, m, i + 1));
const endpoint = date === draft.start || date === draft.end;
const inRange = date >= rangeStart && date <= rangeEnd;
return <button key={date} type="button" aria-label={date} aria-pressed={endpoint}
onClick={() => choose(date)} onMouseEnter={() => { if (!draft.end) setHovered(date); }}
className={`relative my-0.5 h-10 text-xs font-semibold transition focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-blue-500 ${endpoint ? 'rounded-xl bg-blue-600 text-white shadow-sm' : inRange ? 'bg-blue-50 text-blue-700' : 'rounded-xl text-slate-600 hover:bg-slate-100'} ${date === today && !endpoint ? 'font-black text-blue-600' : ''}`}>
{i + 1}{date === today && <span className={`absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${endpoint ? 'bg-white' : 'bg-blue-500'}`} />}
</button>;
})}
</div>
</div>;
};
return <>
<button type="button" data-testid="asset-flow-date-range" aria-haspopup="dialog" onClick={open}
className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-medium text-slate-400">统计日期 · 运维签章时间</span>
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
</span>
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} 天</span>
<ChevronDown size={16} className="shrink-0 text-slate-400" />
</button>
<dialog ref={dialog} aria-labelledby="flow-date-title" onClick={e => { if (e.target === e.currentTarget) dialog.current?.close(); }}
className="fixed inset-0 m-auto max-h-[90dvh] w-[calc(100%-24px)] max-w-[680px] overflow-y-auto rounded-3xl border-0 bg-white p-0 text-slate-700 shadow-2xl backdrop:bg-slate-950/30 backdrop:backdrop-blur-sm">
<div className="p-5 sm:p-6">
<div className="flex items-start justify-between gap-3">
<div><h3 id="flow-date-title" className="text-base font-bold text-slate-900">选择统计日期</h3><p className="mt-1 text-xs text-slate-400">先选开始日期,再选结束日期</p></div>
<button type="button" aria-label="关闭日期选择" onClick={() => dialog.current?.close()} className="rounded-full p-2 text-slate-400 hover:bg-slate-100"><X size={18} /></button>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{quickPicks.map(pick => <button key={pick.label} type="button" onClick={() => { setDraft(pick.range); setMonth(parseDate(pick.range.start)); setSelecting('start'); setHovered(''); }}
className={`rounded-lg border px-3 py-2 text-xs font-medium transition ${draft.start === pick.range.start && draft.end === pick.range.end ? 'border-blue-200 bg-blue-50 text-blue-600' : 'border-slate-100 text-slate-500 hover:border-blue-200 hover:bg-blue-50'}`}>{pick.label}</button>)}
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
{(['start', 'end'] as const).map(field => <button key={field} type="button" onClick={() => { setSelecting(field); setHovered(''); setMonth(parseDate(draft[field] || draft.start)); }}
className={`rounded-xl border px-3 py-2.5 text-left transition ${selecting === field ? 'border-blue-400 bg-blue-50/60 ring-2 ring-blue-50' : 'border-slate-200 bg-slate-50/50'}`}>
<span className="block text-[10px] text-slate-400">{field === 'start' ? '开始日期' : '结束日期'}</span><span className="mt-1 block text-sm font-bold">{draft[field] || '请选择结束日期'}</span>
</button>)}
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<button type="button" aria-label="上个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronLeft size={18} /></button>
<div className="flex gap-2">
<select aria-label="年份" value={monthStart.getFullYear()} onChange={e => setMonth(new Date(Number(e.target.value), monthStart.getMonth(), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
{Array.from({ length: Math.max(new Date().getFullYear() + 1, monthStart.getFullYear()) - Math.min(2016, monthStart.getFullYear()) + 1 }, (_, i) => Math.min(2016, monthStart.getFullYear()) + i).map(year => <option key={year} value={year}>{year} 年</option>)}
</select>
<select aria-label="月份" value={monthStart.getMonth()} onChange={e => setMonth(new Date(monthStart.getFullYear(), Number(e.target.value), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
{Array.from({ length: 12 }, (_, i) => <option key={i} value={i}>{i + 1} 月</option>)}
</select>
</div>
<button type="button" aria-label="下个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronRight size={18} /></button>
</div>
<div className="mt-3 grid gap-6 sm:grid-cols-2" onMouseLeave={() => setHovered('')}>{calendar(0)}{calendar(1)}</div>
</div>
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4 sm:px-6">
<span role="status" className={`text-xs ${count > 370 ? 'text-red-500' : 'text-slate-400'}`}>{!draft.end ? '请选择结束日期' : count > 370 ? '最多选择 370 天' : `已选 ${count} 天(包含首尾)`}</span>
<div className="flex shrink-0 gap-2">
<button type="button" onClick={() => dialog.current?.close()} className="rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-500 hover:bg-slate-100">取消</button>
<button type="button" disabled={!valid} onClick={() => { onChange(draft); dialog.current?.close(); }} className="flex items-center gap-1 rounded-xl bg-blue-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"><Check size={14} />应用日期</button>
</div>
</div>
</dialog>
</>;
}
@@ -1,129 +0,0 @@
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';
import { FlowDateRangePicker } from './FlowDateRangePicker';
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>
<p className="mt-1 text-[11px] text-slate-400">按运维签章时间统计;交车包含替换交车,合计=交车+还车,同车多笔业务分别计次。</p>
<FlowDateRangePicker value={flowRange} onChange={setFlowRange} />
<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) => (
<button key={type} type="button"
data-testid={`asset-flow-total-${type}`}
disabled={flowLoading || !flowStats?.totals[type]}
onClick={() => flowStats && setSelectedFlow({ start: flowStats.start, end: flowStats.end, type })}
className="border-l border-slate-200/70 px-2 transition hover:bg-slate-100 focus-visible:outline-blue-500 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'}`}>
{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'}`}>{type === 'replaced' ? '其中:替换交车' : FLOW_META[type].label}</div>
</button>
))}
</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.slice().sort((a, b) => b.date.localeCompare(a.date)).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'}`}>{type === 'replaced' ? '其中:替换交车' : 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>
);
}
@@ -1,162 +0,0 @@
import { InventoryStatusCounts } from './InventoryStatusCounts';
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 asset-table-scroll 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>
<th className="p-3 font-semibold text-center text-amber-700 w-28">其中异动</th>
<th className="p-3 font-semibold text-center text-blue-700 w-28">其中待交车</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"
tabIndex={0} onKeyDown={e=>{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleInventoryRegion(region);}}} onClick={() => toggleInventoryRegion(region)}
>
<td colSpan={6} 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>
<InventoryStatusCounts rows={Object.values(cities).flat()} />
</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>
<td className="p-3 text-center font-bold text-amber-700">{stat.abnormal ?? 0}</td>
<td className="p-3 text-center font-bold text-blue-700">{stat.pending ?? 0}</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"
tabIndex={0} onKeyDown={e=>{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleInventoryType(type);}}} onClick={() => toggleInventoryType(type)}
>
<td colSpan={6} 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>
<InventoryStatusCounts rows={Object.values(models).flat()} />
</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>
<td className="p-3 text-center font-bold text-amber-700">{stat.abnormal ?? 0}</td>
<td className="p-3 text-center font-bold text-blue-700">{stat.pending ?? 0}</td>
</motion.tr>
))}
</React.Fragment>
))}
</AnimatePresence>
</React.Fragment>
))
)}
</tbody>
</table>
</div>
);
}
@@ -1,122 +0,0 @@
import { InventoryStatusCounts } from './InventoryStatusCounts';
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 flex-wrap gap-2 justify-between items-center cursor-pointer"
tabIndex={0} onKeyDown={e=>{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleInventoryRegion(region);}}} 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>
<InventoryStatusCounts rows={Object.values(cities).flat()} />
</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 flex-wrap gap-2 justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
<div className="flex flex-col">
<span className="text-slate-400 text-[11px]">{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 className="w-full border-t border-slate-100 pt-2"><InventoryStatusCounts rows={[stat]} /></div>
</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 flex-wrap gap-2 justify-between items-center cursor-pointer"
tabIndex={0} onKeyDown={e=>{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleInventoryType(type);}}} 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>
<InventoryStatusCounts rows={Object.values(models).flat()} />
</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 flex-wrap gap-2 justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
<div className="flex flex-col">
<span className="text-slate-400 text-[11px]">{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 className="w-full border-t border-slate-100 pt-2"><InventoryStatusCounts rows={[stat]} /></div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
))
)}
</div>
);
}
@@ -1,210 +0,0 @@
import { InventoryStatusCounts } from './InventoryStatusCounts';
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 data-testid="inventory-statistics" 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-3 ml-auto pr-2">
<div className="flex flex-col items-end">
<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 && (
<>
<button type="button" aria-label="关闭库存筛选" className="fixed inset-0 z-40 bg-slate-900/30" onClick={()=>setIsInventoryFilterOpen(false)} />
<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="asset-filter-panel 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-sm 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="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-slate-100 px-4 py-3" data-testid="inventory-status-summary">
<InventoryStatusCounts rows={filteredInventoryStats} />
<span className="text-[10px] text-slate-400">随当前筛选统计,已包含在库存总数中</span>
</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>
)}
{filteredInventoryStats.length === 0 && <div className="py-12 text-center"><p className="text-sm font-medium text-slate-700">没有符合条件的库存</p><button type="button" onClick={()=>setInventoryFilters({region:'',city:'',brand:'',type:'',model:''})} className="mt-3 rounded-xl bg-blue-50 px-4 py-2 text-sm text-blue-700">清除筛选</button></div>}
{/* Desktop View Table */}
<InventoryDesktopTable {...listProps} />
{/* Mobile View */}
<InventoryMobileList {...listProps} />
</div>
);
}
@@ -1,11 +0,0 @@
import type { RegionalInventoryStats } from '../../types';
/** Counts are subsets of inventory and use the same filtered rows as the total. */
export function InventoryStatusCounts({ rows }: { rows: RegionalInventoryStats[] }) {
const abnormal = rows.reduce((sum, row) => sum + (row.abnormal ?? 0), 0);
const pending = rows.reduce((sum, row) => sum + (row.pending ?? 0), 0);
return <span className="inline-flex flex-wrap gap-x-3 gap-y-1 text-[11px] font-normal" data-testid="inventory-status-counts">
<span className="text-amber-700">其中异动 <strong>{abnormal}</strong> 台</span>
<span className="text-blue-700">其中待交车 <strong>{pending}</strong> 台</span>
</span>;
}
@@ -1,57 +0,0 @@
import { Activity, TriangleAlert, PlusCircle, Truck, Warehouse, ChevronRight } from 'lucide-react';
import type { SummaryMetricsProps } from './types';
export function SummaryMetrics({ summary, operatingRate, inventoryRate, setVehicleSelection }: SummaryMetricsProps) {
const open = (category: 'Operating' | 'Inventory' | 'Abnormal' | 'Pending' | undefined, title: string) =>
setVehicleSelection({ batch: 'All', model: 'All', location: 'All', category, source: 'asset', title });
return <div className="mb-3 space-y-2">
<button type="button" onClick={() => open(undefined, '资产概览')}
className="flex items-center gap-2 px-1 py-2 text-xs text-slate-500 hover:text-blue-600">
<Truck size={15} /><span>资产总数</span><strong className="text-base text-slate-800">{summary.totalAssets.toLocaleString()}</strong><span>台</span><ChevronRight size={14} />
</button>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<section data-testid="asset-operating-group" className="overflow-hidden rounded-2xl border border-blue-100 bg-white shadow-sm">
<button type="button" onClick={() => open('Operating', '总运营车辆')}
className="flex w-full items-center justify-between gap-3 p-4 text-left hover:bg-blue-50/50">
<div className="flex items-center gap-3">
<span className="rounded-xl bg-blue-50 p-2.5 text-blue-600"><Activity size={20} /></span>
<div><h3 className="text-sm font-bold text-slate-800">总运营</h3><p className="mt-1 text-xs text-slate-500">租赁 + 自营</p></div>
</div>
<div className="flex items-center gap-2"><strong className="text-2xl font-bold text-blue-600">{summary.operating.total}</strong><span className="text-xs text-slate-400">台</span><ChevronRight size={16} className="text-slate-400" /></div>
</button>
<div className="grid grid-cols-2 divide-x divide-blue-100 border-t border-blue-50 bg-blue-50/30 px-4 py-3 text-sm">
<div className="flex items-center justify-between gap-2 pr-4"><span className="text-slate-500">租赁</span><strong className="text-slate-800">{summary.operating.leased}</strong></div>
<div className="flex items-center justify-between gap-2 pl-4"><span className="text-slate-500">自营</span><strong className="text-slate-800">{summary.operating.self}</strong></div>
</div>
</section>
<section data-testid="asset-inventory-group" className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<button type="button" onClick={() => open('Inventory', '总库存车辆')}
className="flex w-full items-center justify-between gap-3 p-4 text-left hover:bg-slate-50">
<div className="flex items-center gap-3">
<span className="rounded-xl bg-slate-100 p-2.5 text-slate-600"><Warehouse size={20} /></span>
<div><h3 className="text-sm font-bold text-slate-800">总库存</h3><p className="mt-1 text-xs text-slate-500">待运营 + 可运营</p></div>
</div>
<div className="flex items-center gap-2"><strong className="text-2xl font-bold text-slate-800">{summary.inventory.total}</strong><span className="text-xs text-slate-400">台</span><ChevronRight size={16} className="text-slate-400" /></div>
</button>
<div className="border-t border-slate-100 bg-slate-50/60 px-4 py-3">
<p className="mb-2 text-[11px] text-slate-500">以下车辆已包含在总库存中</p>
<div className="grid grid-cols-2 gap-2">
<button type="button" data-testid="asset-abnormal-card" onClick={() => open('Abnormal', '库存异动车辆')}
className="flex items-center justify-between gap-2 rounded-xl border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-700 hover:bg-amber-100">
<span className="flex items-center gap-1.5"><TriangleAlert size={14} />异动</span><span className="flex items-center gap-1 font-bold">{summary.inventory.abnormal}<ChevronRight size={14} /></span>
</button>
<button type="button" data-testid="asset-pending-card" onClick={() => open('Pending', '库存待交车')}
className="flex items-center justify-between gap-2 rounded-xl border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-700 hover:bg-blue-100">
<span className="flex items-center gap-1.5"><PlusCircle size={14} />待交车</span><span className="flex items-center gap-1 font-bold">{summary.pendingDelivery}<ChevronRight size={14} /></span>
</button>
</div>
</div>
</section>
</div>
<div data-testid="asset-operation-ratio-strip" className="flex flex-wrap items-center gap-x-5 gap-y-1 px-1 py-1 text-xs text-slate-500">
<span>运营率 <strong className="text-blue-600">{operatingRate.toFixed(1)}%</strong></span>
<span>库存率 <strong className="text-slate-700">{inventoryRate.toFixed(1)}%</strong></span>
</div>
</div>;
}
@@ -1,72 +0,0 @@
import type { Dispatch, SetStateAction } from 'react';
import type { FlowDetailItem, FlowStatsResponse, FlowType } from '../../api';
import type {
DateRange,
FlowSelection,
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<FlowSelection | 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;
};
@@ -1,22 +0,0 @@
import { useEffect, type RefObject } from 'react';
export function useDialogFocus(open: boolean, ref: RefObject<HTMLDivElement | null>, close: () => void) {
useEffect(() => {
if (!open) return;
const previous = document.activeElement as HTMLElement | null;
const overflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const frame = requestAnimationFrame(() => ref.current?.focus());
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') { event.preventDefault(); close(); }
if (event.key !== 'Tab') return;
const items = Array.from(ref.current?.querySelectorAll<HTMLElement>('button:not([disabled]), input, select, a[href], [tabindex="0"]') || []).filter(el=>el.getClientRects().length>0);
const first = items[0]; const last = items[items.length-1];
if (!first) { event.preventDefault(); return; }
if (event.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) {event.preventDefault();last.focus();}
else if (!event.shiftKey && (document.activeElement === last || document.activeElement === ref.current)) {event.preventDefault();first.focus();}
};
document.addEventListener('keydown',onKey);
return () => {cancelAnimationFrame(frame);document.removeEventListener('keydown',onKey);document.body.style.overflow=overflow;previous?.focus();};
}, [open, ref, close]);
}
@@ -0,0 +1,424 @@
import { useState } from "react";
import { InventoryAnalysis } from "./InventoryAnalysis";
import { inventoryTotals, type InventoryScope } from "./inventory-view";
import { InventorySection } from "./InventorySection";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardAction,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import {
Package,
Truck,
ArrowLeftRight,
AlertTriangle,
Activity,
Warehouse,
ArrowUpRight,
} from "lucide-react";
import type {
SummaryData,
TypeSummary,
RegionalInventoryStats,
ModelSummary,
BatchSummary,
} from "../types";
import type { VehicleModalCategory, VehicleModalSelection } from "../model";
import type { AssetSection } from "./useAssetHash";
import {
baseSelection,
ProportionBar,
sectionTabsListClass,
sectionTabsTriggerClass,
type OpenVehicles,
} from "./shared";
import { DataGrid, type Column } from "./DataGrid";
import { ExportButton } from "./export";
import { FlowStatistics } from "./FlowStatistics";
/** 运营率环形图(SVG donut)。 */
function RateDonut({ rate, size = 48 }: { rate: number; size?: number }) {
const stroke = 6;
const r = (size - stroke) / 2;
const circumference = 2 * Math.PI * r;
const filled = (Math.min(100, Math.max(0, rate)) / 100) * circumference;
return (
<div
className="relative shrink-0"
style={{ width: size, height: size }}
role="img"
aria-label={`运营率 ${rate.toFixed(1)}%`}
>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="-rotate-90"
aria-hidden="true"
>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="var(--fill-3)"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="var(--blue)"
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={`${filled} ${circumference}`}
className="transition-[stroke-dasharray] duration-700 ease-out"
/>
</svg>
<span
className="absolute inset-0 grid place-items-center text-[11px] font-bold text-[var(--label)]"
aria-hidden="true"
>
{rate.toFixed(0)}%
</span>
</div>
);
}
export function AssetOverview({
summary: s,
types,
inventory,
subject,
section,
onSectionChange,
open,
}: {
summary: SummaryData;
types: TypeSummary[];
inventory: RegionalInventoryStats[];
subject: string | null;
section: AssetSection;
onSectionChange: (section: AssetSection) => void;
open: OpenVehicles;
}) {
const [inventoryScope, setInventoryScope] = useState<InventoryScope | null>(
null,
);
const inventoryCounts = inventoryTotals(inventory);
const operatingRate = s.totalAssets
? (s.operating.total / s.totalAssets) * 100
: 0;
const show = (category?: VehicleModalCategory) => {
if (
category === "Inventory" ||
category === "Abnormal" ||
category === "Pending"
)
setInventoryScope({
rows: inventory,
label: "全部库存",
initialCategory: category === "Inventory" ? undefined : category,
});
else open({ ...baseSelection, category });
};
return (
<div className="asset-overview grid min-w-0 gap-3">
<div className="grid gap-2 md:grid-cols-2" aria-label="资产构成">
<Card size="sm" className="asset-kpi ring-0 shadow-[var(--shadow-1)]">
<CardHeader>
<CardTitle className="flex items-center gap-2 font-semibold group-data-[size=sm]/card:text-base text-[var(--label)] dark:text-[var(--cyan)]">
<Activity aria-hidden="true" />
总运营
</CardTitle>
<CardAction>
<div className="flex items-center gap-2.5">
<RateDonut rate={operatingRate} />
<div className="text-right">
<p className="text-xs text-[var(--label-secondary)]">
运营率
</p>
<p className="text-sm font-semibold tabular-nums whitespace-nowrap text-[var(--label)]">
{operatingRate.toFixed(1)}%
</p>
</div>
</div>
</CardAction>
<CardDescription>租赁 + 自营</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-end justify-between gap-3">
<Button
variant="ghost"
className="asset-kpi-number"
onClick={() => show("Operating")}
>
{s.operating.total.toLocaleString()}{" "}
<span className="asset-kpi-unit">台</span>
<ArrowUpRight aria-hidden="true" />
</Button>
<div className="flex gap-2">
<Badge className="bg-[var(--bg-tertiary)] text-[var(--label)]">
租赁 {s.operating.leased}
</Badge>
<Badge className="bg-[var(--bg-tertiary)] text-[var(--label)]">
自营 {s.operating.self}
</Badge>
</div>
<ProportionBar
label={`租赁 ${s.operating.leased} 台,自营 ${s.operating.self} 台`}
segments={[
{
value: s.operating.leased,
className: "bg-[var(--blue)]",
title: "租赁",
},
{
value: s.operating.self,
className: "bg-[var(--teal)]",
title: "自营",
},
]}
/>
</CardContent>
</Card>
<Card
size="sm"
className="asset-kpi inventory ring-0 shadow-[var(--shadow-1)]"
>
<CardHeader>
<CardTitle className="flex items-center gap-2 font-semibold group-data-[size=sm]/card:text-base text-[var(--label)] dark:text-[var(--cyan)]">
<Warehouse aria-hidden="true" />
总库存
</CardTitle>
<CardDescription>
待运营 + 可运营;异动、待交车已包含在内
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-end justify-between gap-3">
<Button
variant="ghost"
className="asset-kpi-number"
onClick={() => show("Inventory")}
>
{s.inventory.total.toLocaleString()}{" "}
<span className="asset-kpi-unit">台</span>
<ArrowUpRight aria-hidden="true" />
</Button>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
className="border-orange-200 bg-orange-50 text-orange-800 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-200"
disabled={!s.inventory.abnormal}
onClick={() => show("Abnormal")}
>
<AlertTriangle
data-icon="inline-start"
className="text-[var(--orange)]"
/>
异动 {s.inventory.abnormal}
</Button>
<Button
variant="outline"
size="sm"
className="border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-200"
disabled={!s.pendingDelivery}
onClick={() => show("Pending")}
>
待交车 {s.pendingDelivery}
</Button>
</div>
<ProportionBar
label={`可运营 ${inventoryCounts.available} 台,待运营 ${inventoryCounts.awaiting} 台`}
segments={[
{
value: inventoryCounts.available,
className: "bg-[var(--blue)]",
title: "可运营",
},
{
value: inventoryCounts.awaiting,
className: "bg-[var(--inventory-awaiting)]",
title: "待运营",
},
]}
/>
</CardContent>
</Card>
</div>
<Tabs
value={section}
onValueChange={(value) => onSectionChange(value as AssetSection)}
>
<TabsList className={sectionTabsListClass}>
<TabsTrigger className={sectionTabsTriggerClass} value="inventory">
<Package />
库存统计
</TabsTrigger>
<TabsTrigger className={sectionTabsTriggerClass} value="models">
<Truck />
车型资产
</TabsTrigger>
<TabsTrigger className={sectionTabsTriggerClass} value="flow">
<ArrowLeftRight />
交还车统计
</TabsTrigger>
</TabsList>
<TabsContent value="inventory">
<InventorySection
inventory={inventory}
subject={subject}
inspect={setInventoryScope}
/>
</TabsContent>
<TabsContent value="models">
<Card
size="sm"
className="bg-[var(--bg-tertiary)] ring-0 shadow-[var(--shadow-2)]"
>
<CardHeader>
<CardTitle className="font-semibold group-data-[size=sm]/card:text-base text-[var(--label)] dark:text-[var(--cyan)]">
车型资产
</CardTitle>
<CardDescription>
按车型、型号与批次逐层查看;本周交还车按车辆去重。
</CardDescription>
</CardHeader>
<CardContent>
<Accordion type="multiple">
{types.map((type) => (
<AccordionItem key={type.type} value={type.type}>
<AccordionTrigger>
{type.type} · {type.totalAssets} 台 · 库存{" "}
{type.totalInventory}
</AccordionTrigger>
<AccordionContent>
<ModelTable
rows={type.models}
open={open}
selection={{ ...baseSelection }}
/>
<Accordion type="multiple">
{type.models.map((model) => (
<AccordionItem key={model.model} value={model.model}>
<AccordionTrigger>
{model.model} · 查看批次
</AccordionTrigger>
<AccordionContent>
<ModelTable
rows={model.batches}
open={open}
selection={{
...baseSelection,
model: model.model,
}}
batch
/>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="flow">
<FlowStatistics subject={subject} />
</TabsContent>
</Tabs>
{inventoryScope && (
<InventoryAnalysis
scope={inventoryScope}
subject={subject}
close={() => setInventoryScope(null)}
/>
)}
</div>
);
}
function ModelTable({
rows,
open,
selection,
batch = false,
}: {
rows: (ModelSummary | BatchSummary)[];
open: OpenVehicles;
selection: VehicleModalSelection;
batch?: boolean;
}) {
const data = rows.map((row) => ({
...row,
name: "model" in row ? row.model : row.batch,
}));
type Row = (typeof data)[number];
const columns: Column<Row>[] = [
{ key: "name", label: batch ? "批次" : "型号", width: 190 },
...(
[
["total", "资产", undefined],
["operating", "运营", "Operating"],
["inventory", "库存", "Inventory"],
["abnormal", "异动", "Abnormal"],
["pending", "待交车", "Pending"],
["weeklyDelivered", "本周交车", "Delivered"],
["weeklyReturned", "本周还车", "Returned"],
["weeklyReplaced", "替换交车", "Replaced"],
] as const
).map(([key, label, category]) => ({
key,
label,
action: (row: Row) =>
open({
...selection,
...(batch ? { batch: row.name } : { model: row.name }),
category,
}),
})),
{
key: "inventoryRegions",
label: "库存分布",
width: 260,
format: (value) => formatInventoryRegions(value),
tooltip: (value) => formatInventoryRegions(value),
},
];
return (
<div className="grid gap-2">
<ExportButton
rows={data}
columns={columns}
filename={batch ? "批次资产" : "型号资产"}
sheetName={batch ? "批次资产" : "型号资产"}
/>
<DataGrid
rows={data}
columns={columns}
label={batch ? "批次资产" : "型号资产"}
/>
</div>
);
}
/** 库存分布:只展示有库存的区域,避免 0 值噪声;截断时由 tooltip 展示完整内容。 */
function formatInventoryRegions(value: unknown): string {
const parts = Object.entries(value as Record<string, number>)
.filter(([, count]) => count > 0)
.map(([region, count]) => `${region} ${count}`);
return parts.join(" · ") || "—";
}
@@ -0,0 +1,695 @@
import { useEffect, useMemo, useState } from "react";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import type {
DeptGroup,
ManagerStats,
RegionGroup,
RegionTypeBreakdown,
CustomerStats,
} from "../types";
import {
deriveDepartmentView,
deriveCustomerView,
type CustomerFilters,
} from "../model";
import { fetchRegionStats, fetchRegionChart } from "../api";
const APPLE_CHART_COLORS = [
"var(--indigo)",
"var(--blue)",
"var(--teal)",
"var(--orange)",
"var(--green)",
];
import {
baseSelection,
FilterFields,
PrivateText,
unique,
Failure,
type OpenVehicles,
} from "./shared";
import { DataGrid, type Column } from "./DataGrid";
import { ExportButton } from "./export";
import { Chart } from "./Chart";
const typeFields = [
["t4_5", "4.5T普货"],
["t4_5c", "4.5T冷链"],
["t18", "18T"],
["t49", "49T"],
["trailer", "挂车"],
["other", "其他"],
] as const;
export function DepartmentView({
data,
open,
}: {
data: DeptGroup[];
open: OpenVehicles;
}) {
const [mode, setMode] = useState("department");
const [manager, setManager] = useState("");
const view = deriveDepartmentView(data, manager || "All");
const cols: Column<DeptGroup>[] = [
{ key: "department", label: "部门", width: 180 },
{
key: "totalAssets",
label: "车辆总数",
action: (r) =>
open({
...baseSelection,
source: "department",
category: "Operating",
department: r.department,
}),
},
{
key: "operatingCount",
label: "出勤车辆",
action: (r) =>
open({
...baseSelection,
source: "department",
category: "Operating",
department: r.department,
attendance: "active",
}),
},
{
key: "idleCount",
label: "未出勤车辆",
action: (r) =>
open({
...baseSelection,
source: "department",
category: "Operating",
department: r.department,
attendance: "idle",
}),
},
{
key: "attendanceRate",
label: "出勤率",
numeric: true,
format: (v) => `${Number(v).toFixed(1)}%`,
},
{
key: "avgMileage",
label: "平均里程(km)",
width: 160,
numeric: true,
format: (v) => Number(v).toFixed(1),
},
];
const managerCols: Column<ManagerStats>[] = [
{ key: "manager", label: "业务负责人", private: true },
{ key: "department", label: "部门", width: 160 },
...typeFields.map(([key, type]) => ({
key,
label: type,
action: (r: ManagerStats) =>
open({
...baseSelection,
source: "department",
category: "Operating",
department: r.department,
manager: r.manager,
type,
}),
})),
{
key: "total",
label: "合计",
action: (r) =>
open({
...baseSelection,
source: "department",
category: "Operating",
department: r.department,
manager: r.manager,
}),
},
];
return (
<Card
size="sm"
className="bg-[var(--bg-tertiary)] ring-0 shadow-[var(--shadow-2)]"
>
<CardHeader>
<CardTitle className="font-semibold group-data-[size=sm]/card:text-base text-[var(--label)] dark:text-[var(--cyan)]">
部门运营
</CardTitle>
<CardDescription>查看部门出勤表现与负责人车辆分布。</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<Tabs value={mode} onValueChange={setMode}>
<TabsList className="min-h-11">
<TabsTrigger className="min-h-11 px-3" value="department">按部门</TabsTrigger>
<TabsTrigger className="min-h-11 px-3" value="manager">按负责人</TabsTrigger>
</TabsList>
</Tabs>
{mode === "department" ? (
<ExportButton
rows={data}
columns={cols}
filename="部门运营-按部门"
sheetName="部门"
/>
) : (
<ExportButton
rows={view.managerStats}
columns={managerCols}
filename="部门运营-按负责人"
sheetName="负责人"
/>
)}
</div>
{mode === "department" ? (
<>
<DataGrid rows={data} columns={cols} label="部门" />
<Accordion type="multiple">
{data.map((d) => (
<AccordionItem key={d.department} value={d.department}>
<AccordionTrigger>
{d.department} · 负责人明细
</AccordionTrigger>
<AccordionContent>
<DataGrid
rows={d.managers}
columns={managerCols}
label="负责人"
/>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</>
) : (
<>
<FilterFields
fields={[
{
label: "负责人",
value: manager,
values: view.managers,
change: setManager,
},
]}
onReset={() => setManager("")}
/>
<DataGrid
rows={view.managerStats}
columns={managerCols}
label="负责人"
/>
</>
)}
</CardContent>
</Card>
);
}
export function RegionView({
data,
subject,
open,
}: {
data: RegionGroup[];
subject: string | null;
open: OpenVehicles;
}) {
const [chartView, setChartView] = useState("region");
const [filters, setFilters] = useState({
region: "",
city: "",
customer: "",
});
const [filtered, setFiltered] = useState<RegionGroup[]>(data);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
useEffect(() => {
let active = true;
if (!filters.region && !filters.city && !filters.customer) {
setFiltered(data);
setLoading(false);
setError("");
return;
}
setLoading(true);
setError("");
fetchRegionStats(filters, subject)
.then((v) => {
if (active) setFiltered(v);
})
.catch((e) => {
if (active) setError(String(e.message || e));
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [filters, subject, data, retry]);
const option = useMemo(
() => ({
color: APPLE_CHART_COLORS,
textStyle: { color: "var(--label-secondary)" },
tooltip: { trigger: "axis" },
grid: { left: 45, right: 15, top: 20, bottom: 45 },
xAxis: {
type: "category",
data: filtered.map((r) => r.region),
axisLabel: { color: "var(--label-secondary)", interval: 0 },
axisLine: { lineStyle: { color: "var(--separator)" } },
},
yAxis: {
type: "value",
minInterval: 1,
axisLabel: { color: "var(--label-secondary)" },
splitLine: { lineStyle: { color: "var(--separator)" } },
},
series: [
{
type: "bar",
name: "车辆",
data: filtered.map((r) => r.totalAssets),
barMaxWidth: 48,
itemStyle: { borderRadius: [4, 4, 0, 0] },
label: {
show: true,
position: "top",
color: "var(--label-secondary)",
},
},
],
}),
[filtered],
);
const breakdown = (rows: RegionTypeBreakdown[], location: string) => (
<DataGrid
rows={rows}
label={`${location}车型`}
columns={[
{ key: "type", label: "车型" },
...(
[
["total", "总数", undefined],
["operating", "运营", "Operating"],
["inventory", "库存", "Inventory"],
["abnormal", "异动", "Abnormal"],
["pending", "待交车", "Pending"],
] as const
).map(([key, label, category]) => ({
key,
label,
action: (r: RegionTypeBreakdown) =>
open({
...baseSelection,
source: "region",
location: filters.city || location,
customer: filters.customer || undefined,
vehicleType: r.type === "其他" ? "区域其他" : r.type,
category,
}),
})),
]}
/>
);
return (
<Card
size="sm"
className="bg-[var(--bg-tertiary)] ring-0 shadow-[var(--shadow-2)]"
>
<CardHeader>
<CardTitle className="font-semibold group-data-[size=sm]/card:text-base text-[var(--label)] dark:text-[var(--cyan)]">
区域分布
</CardTitle>
<CardDescription>
运营与待交车车辆,按区域 → 城市 → 车型查看。
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<FilterFields
fields={[
{
label: "区域",
value: filters.region,
values: data.map((r) => r.region),
change: (v) => setFilters((f) => ({ ...f, region: v, city: "" })),
},
{
label: "城市",
value: filters.city,
values: unique(
data
.filter((r) => !filters.region || r.region === filters.region)
.flatMap((r) => r.cities.map((c) => c.city)),
),
change: (v) => setFilters((f) => ({ ...f, city: v })),
},
{
label: "客户",
value: filters.customer,
values: unique(data.flatMap((r) => r.customers)),
change: (v) => setFilters((f) => ({ ...f, customer: v })),
},
]}
onReset={() => setFilters({ region: "", city: "", customer: "" })}
/>
{error ? (
<Failure message={error} retry={() => setRetry((n) => n + 1)} />
) : loading ? (
<Skeleton className="h-64" />
) : (
<>
<Tabs value={chartView} onValueChange={setChartView}>
<TabsList className="min-h-11">
<TabsTrigger className="min-h-11 px-3" value="region">区域分布</TabsTrigger>
<TabsTrigger className="min-h-11 px-3" value="province">实时省份</TabsTrigger>
</TabsList>
</Tabs>
{chartView === "region" ? (
<Chart option={option} label="各区域车辆数量" />
) : (
<ProvinceChart subject={subject} source="realtime" />
)}
<Accordion type="multiple">
{filtered.map((r) => (
<AccordionItem key={r.region} value={r.region}>
<AccordionTrigger>
{r.region} · {r.totalAssets} 台
</AccordionTrigger>
<AccordionContent>
{breakdown(r.typeBreakdown, r.region)}
<Accordion type="multiple">
{r.cities.map((city) => (
<AccordionItem key={city.city} value={city.city}>
<AccordionTrigger>
{city.city} · {city.totalAssets} 台
</AccordionTrigger>
<AccordionContent>
{breakdown(city.typeBreakdown, city.city)}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
{!filtered.length && (
<CardDescription>暂无符合条件的区域数据</CardDescription>
)}
</>
)}
</CardContent>
</Card>
);
}
const initialCustomer: CustomerFilters = {
customer: [],
brand: "",
department: "",
manager: "",
region: "",
};
export function CustomerView({
data,
departments,
subject,
open,
}: {
data: CustomerStats[];
subject: string | null;
departments: DeptGroup[];
open: OpenVehicles;
}) {
const [filters, setFilters] = useState(initialCustomer);
const [customerSearch, setCustomerSearch] = useState("");
const [chartGroup, setChartGroup] = useState("region");
const view = deriveCustomerView(data, departments, filters);
const option = useMemo(() => {
const counts = new Map<string, number>();
for (const row of view.filtered) {
const name = chartGroup === "region" ? row.region : row.city;
counts.set(
name || "未归属",
(counts.get(name || "未归属") || 0) + row.total,
);
}
return {
color: APPLE_CHART_COLORS,
textStyle: { color: "var(--label-secondary)" },
tooltip: { trigger: "item" },
legend: {
type: "scroll",
bottom: 0,
textStyle: { color: "var(--label-secondary)" },
},
series: [
{
type: "pie",
radius: ["35%", "65%"],
label: { show: false },
data: [...counts].map(([name, value]) => ({ name, value })),
},
],
};
}, [view.filtered, chartGroup]);
const cols: Column<CustomerStats>[] = [
{ key: "customer", label: "客户", width: 220, private: true },
{ key: "manager", label: "负责人", private: true },
{ key: "department", label: "部门", width: 150 },
{ key: "brand", label: "品牌" },
{ key: "region", label: "区域" },
{ key: "city", label: "城市" },
...typeFields.map(([key, type]) => ({
key,
label: type,
action: (r: CustomerStats) =>
open({
...baseSelection,
source: "customer",
category: "Operating",
customer: r.customer,
type,
}),
})),
{
key: "total",
label: "合计",
action: (r) =>
open({
...baseSelection,
source: "customer",
category: "Operating",
customer: r.customer,
}),
},
];
return (
<Card
size="sm"
className="bg-[var(--bg-tertiary)] ring-0 shadow-[var(--shadow-2)]"
>
<CardHeader>
<CardTitle className="font-semibold group-data-[size=sm]/card:text-base text-[var(--label)] dark:text-[var(--cyan)]">
客户资产
</CardTitle>
<CardDescription>
按客户、负责人、品牌与区域查看车辆分布。
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">
{filters.customer.length
? `已选 ${filters.customer.length} 个客户`
: "选择客户(可多选)"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<Input
placeholder="搜索客户"
aria-label="搜索客户选项"
value={customerSearch}
onChange={(e) => setCustomerSearch(e.target.value)}
/>
<div className="grid max-h-64 gap-3 overflow-y-auto py-3">
{view.customerNames
.filter((c) => c.includes(customerSearch))
.map((c) => (
<Field orientation="horizontal" key={c}>
<Checkbox
id={`customer-${c}`}
checked={filters.customer.includes(c)}
onCheckedChange={(checked) =>
setFilters((f) => ({
...f,
customer: checked
? [...f.customer, c]
: f.customer.filter((x) => x !== c),
}))
}
/>
<FieldLabel htmlFor={`customer-${c}`}>
<PrivateText>{c}</PrivateText>
</FieldLabel>
</Field>
))}
</div>
</PopoverContent>
</Popover>
<ExportButton
rows={view.filtered}
columns={cols}
filename="客户资产"
sheetName="客户资产"
/>
</div>
<FilterFields
fields={[
{
label: "品牌",
value: filters.brand,
values: view.brands,
change: (v) => setFilters((f) => ({ ...f, brand: v })),
},
{
label: "部门",
value: filters.department,
values: view.departments,
change: (v) =>
setFilters((f) => ({ ...f, department: v, manager: "" })),
},
{
label: "负责人",
value: filters.manager,
values: unique(
view.managersByDepartment
.filter(
(d) =>
!filters.department ||
d.department === filters.department,
)
.flatMap((d) => d.managers),
),
change: (v) => setFilters((f) => ({ ...f, manager: v })),
},
{
label: "区域",
value: filters.region,
values: view.regions,
change: (v) => setFilters((f) => ({ ...f, region: v })),
},
]}
onReset={() => setFilters(initialCustomer)}
/>
<Tabs value={chartGroup} onValueChange={setChartGroup}>
<TabsList className="min-h-11">
<TabsTrigger className="min-h-11 px-3" value="region">区域分布</TabsTrigger>
<TabsTrigger className="min-h-11 px-3" value="city">城市分布</TabsTrigger>
<TabsTrigger className="min-h-11 px-3" value="province">省份分布</TabsTrigger>
</TabsList>
</Tabs>
{chartGroup === "province" ? (
<ProvinceChart subject={subject} source="vehicle" />
) : (
<Chart option={option} label="客户车辆地区分布" />
)}
<DataGrid rows={view.filtered} columns={cols} label="客户资产" />
</CardContent>
</Card>
);
}
function ProvinceChart({
subject,
source,
}: {
subject: string | null;
source: "vehicle" | "realtime";
}) {
const [rows, setRows] = useState<{ name: string; value: number }[] | null>(
null,
);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
useEffect(() => {
let active = true;
setRows(null);
setError("");
fetchRegionChart("province", 5, source, subject)
.then((v) => {
if (active) setRows(v);
})
.catch((e) => {
if (active) setError(e.message || "加载失败");
});
return () => {
active = false;
};
}, [subject, source, retry]);
const option = useMemo(
() => ({
color: APPLE_CHART_COLORS,
textStyle: { color: "var(--label-secondary)" },
tooltip: { trigger: "item" },
legend: {
type: "scroll",
bottom: 0,
textStyle: { color: "var(--label-secondary)" },
},
series: [
{
type: "pie",
radius: ["35%", "65%"],
label: { show: false },
data: rows || [],
},
],
}),
[rows],
);
return (
<div className="grid gap-2">
<CardDescription>
{source === "realtime" ? "实时定位" : "车辆档案"}省份分布 ·
当前公司范围,不受表格筛选影响
</CardDescription>
{error ? (
<Failure message={error} retry={() => setRetry((n) => n + 1)} />
) : rows ? (
<Chart option={option} label="省份分布" />
) : (
<Skeleton className="h-64" />
)}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { lazy, Suspense } from "react";
import type { EChartsCoreOption } from "echarts/core";
import { Skeleton } from "@/components/ui/skeleton";
const EChart = lazy(() => import("./EChartImpl"));
export function Chart(props: { option: EChartsCoreOption; label: string }) {
return (
<Suspense fallback={<Skeleton className="h-64" />}>
<EChart {...props} />
</Suspense>
);
}
+281
View File
@@ -0,0 +1,281 @@
import { useMemo, useState, useEffect, lazy, Suspense, useRef } from "react";
import { ArrowUpRight, Search, SearchX, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useDemoMode } from "@/components/Blur";
import { Count, useMobile } from "./shared";
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import { Skeleton } from "@/components/ui/skeleton";
const DesktopGrid = lazy(
() => import("./DesktopGrid"),
) as typeof import("./DesktopGrid").default;
export type Column<T> = {
key: keyof T & string;
label: string;
width?: number;
private?: boolean;
primary?: boolean;
action?: (row: T) => void;
format?: (value: unknown, row: T) => string;
numeric?: boolean;
tooltip?: (value: unknown, row: T) => string;
};
export function DataGrid<T extends object>({
rows,
columns,
label = "明细",
search = true,
}: {
rows: T[];
columns: Column<T>[];
label?: string;
search?: boolean;
}) {
const mobile = useMobile();
const demo = useDemoMode();
const [query, setQuery] = useState("");
const [page, setPage] = useState(0);
const [compact, setCompact] = useState(true);
const top = useRef<HTMLDivElement>(null);
const pageSize = 20;
const display = (row: T, col: Column<T>) =>
col.private && demo
? "••••••"
: col.format
? col.format(row[col.key], row)
: String(row[col.key] ?? "—");
const filtered = useMemo(() => {
const term = query.trim().toLocaleLowerCase();
return rows.filter(
(row) =>
!term ||
columns.some((col) =>
display(row, col).toLocaleLowerCase().includes(term),
),
);
}, [rows, columns, query, demo]);
useEffect(() => setPage(0), [query, rows.length]);
const pages = Math.max(1, Math.ceil(filtered.length / pageSize));
const currentPage = Math.min(page, pages - 1);
const renderValue = (row: T, col: Column<T>) => {
if (col.private && demo) return "••••••";
if (col.action)
return (
<Count
value={Number(row[col.key]) || 0}
label={`${display(row, columns[0])} ${col.label}`}
onClick={() => col.action!(row)}
/>
);
const tooltip = col.tooltip?.(row[col.key], row);
return tooltip ? (
<span title={tooltip}>{display(row, col)}</span>
) : (
display(row, col)
);
};
const metrics = columns.slice(1).filter((col) => col.action || col.numeric);
const textColumns = columns.slice(1).filter((col) => !metrics.includes(col));
const primaryColumns = textColumns.filter(
(col, index) => index < 4 || col.primary,
);
const moreColumns = textColumns.filter(
(col) => !primaryColumns.includes(col),
);
const fields = (row: T, list: Column<T>[]) => (
<dl className="asset-record-fields">
{list.map((col) => (
<div key={col.key} className="min-w-0">
<dt>{col.label}</dt>
<dd>{renderValue(row, col)}</dd>
</div>
))}
</dl>
);
const changePage = (next: number) => {
setPage(next);
top.current?.scrollIntoView({ block: "start", behavior: "instant" });
};
return (
<div className="grid min-w-0 gap-3 scroll-mt-4" ref={top}>
<div className="asset-grid-toolbar">
<p className="text-xs text-[var(--label-secondary)]" role="status">
{query
? `找到 ${filtered.length} / ${rows.length} 条记录`
: `共 ${filtered.length} 条记录`}
{columns.some((col) => col.action) && (
<span className="ml-3 hidden md:inline">蓝色数量可查看明细</span>
)}
</p>
<div className="flex min-w-0 flex-wrap items-center gap-2 max-md:w-full md:flex-nowrap">
{search && (rows.length > 8 || query) && (
<div className="asset-grid-search">
<Search aria-hidden="true" />
<Input
aria-label={`搜索${label}`}
placeholder={`搜索${label}…`}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{query && (
<Button
variant="ghost"
size="icon"
aria-label={`清空${label}搜索`}
onClick={() => setQuery("")}
>
<X />
</Button>
)}
</div>
)}
{!mobile && rows.length > 0 && (
<Button
variant="outline"
size="sm"
aria-pressed={compact}
onClick={() => setCompact((value) => !value)}
>
{compact ? "舒适行高" : "紧凑行高"}
</Button>
)}
</div>
</div>
{!filtered.length ? (
<div className="inventory-empty">
<SearchX aria-hidden="true" />
<strong>暂无符合条件的数据</strong>
{query && (
<Button variant="outline" onClick={() => setQuery("")}>
清空搜索
</Button>
)}
</div>
) : mobile ? (
<>
{filtered
.slice(currentPage * pageSize, (currentPage + 1) * pageSize)
.map((row, index) => (
<article
className="asset-record"
key={`${String(row[columns[0].key])}-${currentPage}-${index}`}
>
<header className="asset-record-head">
<div className="min-w-0">
<p className="mb-1 text-xs text-[var(--label-secondary)]">
{columns[0].label}
</p>
<h4>{renderValue(row, columns[0])}</h4>
</div>
<span className="text-xs text-[var(--label-secondary)]">
{String(currentPage * pageSize + index + 1).padStart(
2,
"0",
)}
</span>
</header>
{primaryColumns.length > 0 && fields(row, primaryColumns)}
{metrics.length > 0 && (
<div className="asset-record-metrics">
{metrics.map((col) => {
const value = Number(row[col.key]) || 0;
const content = (
<>
<span className="text-xs text-[var(--label-secondary)]">
{col.label}
</span>
<span className="asset-metric-value">
{display(row, col)}
{col.action && value > 0 && (
<ArrowUpRight aria-hidden="true" />
)}
</span>
</>
);
return col.action &&
value > 0 &&
!(col.private && demo) ? (
<button
type="button"
className="asset-metric-cell"
key={col.key}
onClick={() => col.action!(row)}
aria-label={`查看${display(row, columns[0])} ${col.label}:${value} 条明细`}
>
{content}
</button>
) : (
<div
key={col.key}
className={`asset-metric-cell ${value ? "" : "asset-metric-empty"}`}
>
{content}
</div>
);
})}
</div>
)}
{moreColumns.length > 0 && (
<Accordion
type="single"
collapsible
className="border-t border-[var(--border)] px-4"
>
<AccordionItem value="details" className="border-0">
<AccordionTrigger className="min-h-11 text-xs">
更多车辆信息 · {moreColumns.length} 项
</AccordionTrigger>
<AccordionContent>
{fields(row, moreColumns)}
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</article>
))}
{filtered.length > pageSize && (
<nav
className="flex items-center justify-between gap-2"
aria-label={`${label}分页`}
>
<Button
variant="outline"
disabled={currentPage === 0}
onClick={() => changePage(currentPage - 1)}
>
上一页
</Button>
<span className="text-xs text-[var(--label-secondary)]">
{currentPage + 1} / {pages}
</span>
<Button
variant="outline"
disabled={currentPage + 1 >= pages}
onClick={() => changePage(currentPage + 1)}
>
下一页
</Button>
</nav>
)}
</>
) : (
<div
className={
filtered.length > pageSize ? "h-[36rem] min-w-0" : "min-w-0"
}
role="region"
aria-label={label}
>
<Suspense fallback={<Skeleton className="h-96" />}>
<DesktopGrid rows={filtered} columns={columns} compact={compact} />
</Suspense>
</div>
)}
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { AgGridReact } from "ag-grid-react";
import {
AllCommunityModule,
ModuleRegistry,
themeQuartz,
type ColDef,
type ICellRendererParams,
} from "ag-grid-community";
import { useDemoMode } from "@/components/Blur";
import { Count } from "./shared";
import type { Column } from "./DataGrid";
ModuleRegistry.registerModules([AllCommunityModule]);
const gridTheme = themeQuartz.withParams({
fontFamily: "inherit",
fontSize: 14,
rowHeight: 48,
headerHeight: 36,
spacing: 6,
borderRadius: 8,
accentColor: "var(--blue)",
backgroundColor: "var(--bg-tertiary)",
foregroundColor: "var(--label)",
headerBackgroundColor: "var(--bg-secondary)",
borderColor: "var(--separator)",
oddRowBackgroundColor: "var(--bg-secondary)",
});
const localeText = {
noRowsToShow: "暂无数据",
page: "页",
of: "/",
to: "–",
more: "更多",
nextPage: "下一页",
previousPage: "上一页",
firstPage: "首页",
lastPage: "末页",
pageSizeSelectorLabel: "每页",
loadingOoo: "加载中…",
filterOoo: "筛选…",
equals: "等于",
notEqual: "不等于",
contains: "包含",
notContains: "不包含",
startsWith: "开头是",
endsWith: "结尾是",
blank: "空白",
notBlank: "非空",
lessThan: "小于",
greaterThan: "大于",
inRange: "范围",
andCondition: "且",
orCondition: "或",
};
export default function DesktopGrid<T extends object>({
rows,
columns,
compact = false,
}: {
rows: T[];
columns: Column<T>[];
compact?: boolean;
}) {
const demo = useDemoMode();
const display = (row: T, col: Column<T>) =>
col.private && demo
? "••••••"
: col.format
? col.format(row[col.key], row)
: String(row[col.key] ?? "—");
const defs: ColDef<T>[] = columns.map((col, index) => {
const numeric = Boolean(col.numeric || col.action);
return {
colId: col.key,
pinned: index === 0 ? "left" : col.key === "total" ? "right" : undefined,
headerName: col.label,
minWidth: col.width || 110,
initialFlex: 1,
cellStyle: numeric ? { textAlign: "right" } : undefined,
cellClass: numeric ? "tabular-nums" : undefined,
headerClass: numeric ? "tabular-nums ag-numeric-header" : undefined,
valueGetter: ({ data }) =>
data ? (demo && col.private ? "••••••" : data[col.key]) : "",
valueFormatter: ({ data }) => (data ? display(data, col) : ""),
cellRenderer: col.action
? (p: ICellRendererParams<T>) =>
p.data && (
<Count
label={`${display(p.data, columns[0])} ${col.label}`}
value={Number(p.data[col.key]) || 0}
onClick={() => col.action!(p.data!)}
/>
)
: col.tooltip
? (p: ICellRendererParams<T>) =>
p.data && (
<span
title={
demo && col.private
? undefined
: col.tooltip!(p.data[col.key], p.data)
}
>
{display(p.data, col)}
</span>
)
: undefined,
};
});
return (
<AgGridReact<T>
theme={gridTheme}
rowHeight={compact ? 36 : 48}
tooltipShowDelay={350}
rowData={rows}
columnDefs={defs}
defaultColDef={{ sortable: true, filter: true, resizable: true }}
domLayout={rows.length <= 20 ? "autoHeight" : "normal"}
pagination={rows.length > 20}
paginationPageSize={20}
paginationPageSizeSelector={[20, 50, 100]}
localeText={localeText}
/>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useRef } from "react";
import * as echarts from "echarts/core";
import { BarChart, LineChart, PieChart } from "echarts/charts";
import {
GridComponent,
TooltipComponent,
LegendComponent,
AriaComponent,
DataZoomComponent,
} from "echarts/components";
import { SVGRenderer } from "echarts/renderers";
import type { EChartsCoreOption } from "echarts/core";
echarts.use([
BarChart,
LineChart,
PieChart,
GridComponent,
TooltipComponent,
LegendComponent,
AriaComponent,
DataZoomComponent,
SVGRenderer,
]);
export default function Chart({
option,
label,
}: {
option: EChartsCoreOption;
label: string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const chart = echarts.init(ref.current, undefined, { renderer: "svg" });
chart.setOption({ ...option, aria: { enabled: true } });
const observer = new ResizeObserver(() => chart.resize());
observer.observe(ref.current);
return () => {
observer.disconnect();
chart.dispose();
};
}, [option]);
return (
<div
ref={ref}
role="img"
aria-label={label}
className="h-64 min-w-0 w-full"
/>
);
}
@@ -0,0 +1,284 @@
import { useCallback, useEffect, useState, useMemo } from "react";
import { zhCN } from "date-fns/locale";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { CalendarDays, Download } from "lucide-react";
import {
fetchFlowStats,
type FlowStatsResponse,
type FlowDetailItem,
type FlowDailyPoint,
} from "../api";
import {
getRecentFlowRange,
getWeeklyFlowRange,
formatLocalDate,
selectFlowDetails,
type FlowSelection,
} from "../model";
import { Failure, useMobile } from "./shared";
import { DataGrid, type Column } from "./DataGrid";
import { exportToXlsx } from "./export";
import { Chart } from "./Chart";
export function FlowStatistics({ subject }: { subject: string | null }) {
const [range, setRange] = useState(getRecentFlowRange);
const [calendarOpen, setCalendarOpen] = useState(false);
const [draft, setDraft] = useState<{ from: Date | undefined; to?: Date }>({
from: new Date(`${range.start}T00:00:00`),
to: new Date(`${range.end}T00:00:00`),
});
const [data, setData] = useState<FlowStatsResponse | null>(null);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
const [selection, setSelection] = useState<FlowSelection | null>(null);
const mobile = useMobile();
useEffect(() => {
let active = true;
setData(null);
setError("");
fetchFlowStats({ ...range, subject })
.then((v) => {
if (active) setData(v);
})
.catch((e) => {
if (active) setError(String(e.message || e));
});
return () => {
active = false;
};
}, [range, subject, retry]);
const details = data && selection ? selectFlowDetails(data, selection) : [];
const columns: Column<FlowDetailItem>[] = [
{ key: "plateNumber", label: "车牌", private: true },
{ key: "typeLabel", label: "类型" },
{ key: "date", label: "业务日期" },
{ key: "eventTime", label: "运维签章时间", width: 180 },
{ key: "submitTime", label: "建单时间", width: 180 },
{ key: "department", label: "部门" },
{ key: "manager", label: "负责人", private: true },
{ key: "customerName", label: "客户", private: true, width: 180 },
];
const exportRows = (rows: FlowDetailItem[], filename: string) =>
exportToXlsx(rows, columns, filename, "交还车明细");
const option = useMemo(
() => ({
color: ["var(--blue)", "var(--orange)", "var(--indigo)"],
textStyle: { color: "var(--label-secondary)" },
tooltip: { trigger: "axis" },
legend: {
data: ["交车", "还车", "替换交车"],
textStyle: { color: "var(--label-secondary)" },
},
grid: { left: 40, right: 20, bottom: 40, top: 40 },
xAxis: {
type: "category",
data: data?.daily.map((d) => d.date.slice(5)) || [],
axisLabel: { color: "var(--label-secondary)" },
axisLine: { lineStyle: { color: "var(--separator)" } },
},
yAxis: {
type: "value",
minInterval: 1,
axisLabel: { color: "var(--label-secondary)" },
splitLine: { lineStyle: { color: "var(--separator)" } },
},
series: ["delivered", "returned", "replaced"].map((key, i) => ({
name: ["交车", "还车", "替换交车"][i],
type: "line",
data: data?.daily.map((d) => d[key as "delivered"]) || [],
})),
}),
[data],
);
return (
<Card
size="sm"
className="bg-[var(--bg-tertiary)] ring-0 shadow-[var(--shadow-2)]"
>
<CardHeader>
<CardTitle className="font-semibold group-data-[size=sm]/card:text-base text-[var(--label)] dark:text-[var(--cyan)]">
交还车统计(车次)
</CardTitle>
<CardDescription>
按运维签章时间统计;交车含替换交车,合计 = 交车 + 还车。
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
<Popover
open={calendarOpen}
onOpenChange={(v) => {
setCalendarOpen(v);
if (v)
setDraft({
from: new Date(`${range.start}T00:00:00`),
to: new Date(`${range.end}T00:00:00`),
});
}}
>
<PopoverTrigger asChild>
<Button variant="outline">
<CalendarDays data-icon="inline-start" />
{range.start} — {range.end}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<Calendar
mode="range"
locale={zhCN}
selected={draft}
onSelect={(v) => setDraft(v || { from: undefined })}
numberOfMonths={mobile ? 1 : 2}
disabled={{ after: new Date() }}
/>
<Button
disabled={!draft.from || !draft.to}
onClick={() => {
if (draft.from && draft.to)
setRange({
start: formatLocalDate(draft.from),
end: formatLocalDate(draft.to),
});
setCalendarOpen(false);
}}
>
应用日期
</Button>
</PopoverContent>
</Popover>
<Button
variant="ghost"
onClick={() => setRange(getRecentFlowRange())}
>
近 7 天
</Button>
<Button
variant="ghost"
onClick={() => setRange(getWeeklyFlowRange())}
>
本周
</Button>
<Button
variant="outline"
disabled={!data?.details.length}
onClick={() =>
data &&
void exportRows(
data.details,
`交还车-${range.start}-${range.end}`,
)
}
>
<Download data-icon="inline-start" />
导出
</Button>
</div>
{error ? (
<Failure message={error} retry={() => setRetry((n) => n + 1)} />
) : !data ? (
<Skeleton className="h-64" />
) : (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{(
[
["total", "合计"],
["delivered", "交车"],
["returned", "还车"],
["replaced", "替换交车"],
] as const
).map(([key, title]) => (
<Card
size="sm"
key={key}
className="bg-[var(--bg-secondary)] ring-0"
>
<CardHeader>
<CardDescription>{title}</CardDescription>
<CardTitle className="text-xl font-semibold tracking-tight text-[var(--blue)] dark:text-[var(--cyan)]">
{key === "total" ? (
data.totals[key]
) : (
<Button
variant="link"
className="h-auto p-0 text-xl font-semibold text-[var(--blue)] dark:text-[var(--cyan)]"
onClick={() => setSelection({ ...range, type: key })}
>
{data.totals[key]}
</Button>
)}
</CardTitle>
</CardHeader>
</Card>
))}
</div>
<Chart option={option} label="每日交还车趋势" />
<DataGrid
rows={data.daily}
label="每日交还车"
columns={[
{ key: "date", label: "业务日期", width: 160 },
{ key: "total", label: "合计", numeric: true },
...(["delivered", "returned", "replaced"] as const).map(
(key, i) => ({
key,
label: ["交车", "还车", "替换交车"][i],
action: (row: FlowDailyPoint) =>
setSelection({ date: row.date, type: key }),
}),
),
]}
/>
</>
)}
</CardContent>
<Dialog open={!!selection} onOpenChange={(v) => !v && setSelection(null)}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-6xl">
<DialogHeader>
<DialogTitle>交还车明细</DialogTitle>
<DialogDescription>
{details.length} 车次 ·{" "}
{selection &&
("date" in selection
? selection.date
: `${selection.start} — ${selection.end}`)}
</DialogDescription>
</DialogHeader>
<Button
variant="outline"
disabled={!details.length}
onClick={() =>
void exportRows(
details,
`交还车明细-${selection && "date" in selection ? selection.date : `${selection?.start}-${selection?.end}`}`,
)
}
>
导出当前明细
</Button>
<DataGrid rows={details} columns={columns} label="交还车明细" />
</DialogContent>
</Dialog>
</Card>
);
}
@@ -0,0 +1,397 @@
import { useMemo, useRef, useState } from "react";
import { ArrowLeft, ArrowUpRight, X, AlertTriangle, Truck } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
inventoryModel,
inventoryTotals,
scopedInventorySelection,
type InventoryScope,
} from "./inventory-view";
import { VehicleDetails } from "./VehicleDetails";
import type { VehicleModalSelection } from "../model";
import type { RegionalInventoryStats } from "../types";
import { emptyInventoryCounts } from "../../../shared/assets/inventory";
export function InventoryAnalysis({
scope,
subject,
close,
}: {
scope: InventoryScope;
subject: string | null;
close: () => void;
}) {
const [selection, setSelection] = useState<VehicleModalSelection | null>(
() =>
scope.initialCategory
? scopedInventorySelection(scope, { category: scope.initialCategory })
: null,
);
const [query, setQuery] = useState("");
const originFocus = useRef(
document.activeElement instanceof HTMLElement
? document.activeElement
: null,
);
const scrollTop = useRef(0);
const total = inventoryTotals(scope.rows);
const open = (extra: Partial<VehicleModalSelection> = {}) =>
setSelection(scopedInventorySelection(scope, extra));
const segments = [
{
key: "available" as const,
label: "可运营",
count: total.available,
color: "var(--blue)",
},
{
key: "awaiting" as const,
label: "待运营",
count: total.awaiting,
color: "var(--inventory-awaiting)",
},
...(total.unclassified
? [
{
key: "unclassified" as const,
label: "待核实状态",
count: total.unclassified,
color: "var(--label-secondary)",
},
]
: []),
];
const variants = useMemo(() => {
const groups = new Map<
string,
{
rows: RegionalInventoryStats[];
color: string;
count: number;
name: string;
brand: string;
operator: string;
}
>();
for (const row of scope.rows) {
const model = inventoryModel(row.model, row.brand);
const colors = row.variants || {
未标注颜色: { ...emptyInventoryCounts(), quantity: row.quantity },
};
for (const [color, counts] of Object.entries(colors)) {
const key = JSON.stringify([row.brand, row.model, color]);
const value = groups.get(key) || {
rows: [],
color,
count: 0,
name: model.name,
brand: row.brand,
operator: model.operator,
};
value.rows.push(row);
value.count += counts.quantity;
groups.set(key, value);
}
}
return [...groups.entries()].sort((a, b) => b[1].count - a[1].count);
}, [scope.rows]);
const shown = variants.filter(([, item]) =>
[item.name, item.brand, item.operator, item.color]
.join(" ")
.toLowerCase()
.includes(query.trim().toLowerCase()),
);
let offset = 0;
return (
<>
<Dialog open={!selection} onOpenChange={(value) => !value && close()}>
<DialogContent
className="asset-ui asset-detail inventory-analysis"
showCloseButton={false}
onCloseAutoFocus={(event) => {
event.preventDefault();
if (!selection) originFocus.current?.focus({ preventScroll: true });
}}
>
<DialogHeader className="asset-detail-header">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={close}
aria-label="返回库存统计"
>
<ArrowLeft />
</Button>
<div className="min-w-0 flex-1">
<p className="analysis-eyebrow">库存分析</p>
<DialogTitle>库存构成</DialogTitle>
</div>
<Button
variant="ghost"
size="icon"
onClick={close}
aria-label="关闭库存分析"
>
<X />
</Button>
</div>
<DialogDescription>
{subject || "全部公司"} · {scope.label}
</DialogDescription>
</DialogHeader>
<div
className="asset-detail-body"
ref={(node) => {
if (node) node.scrollTop = scrollTop.current;
}}
onScroll={(event) => {
scrollTop.current = event.currentTarget.scrollTop;
}}
>
<div className="analysis-overview">
<section
className="analysis-composition"
aria-label="运营状态构成"
>
<div className="analysis-donut">
<svg
viewBox="0 0 200 200"
role="group"
aria-label="库存运营状态占比"
>
<circle
cx="100"
cy="100"
r="78"
fill="none"
stroke="var(--muted)"
strokeWidth="20"
/>
{segments
.filter((segment) => segment.count > 0)
.map((segment) => {
const proportion = segment.count / total.quantity;
const dashOffset = -offset;
offset += proportion * 100;
return (
<circle
key={segment.key}
cx="100"
cy="100"
r="78"
pathLength="100"
fill="none"
stroke={segment.color}
strokeWidth="20"
strokeDasharray={`${proportion * 100} ${100 - proportion * 100}`}
strokeDashoffset={dashOffset}
transform="rotate(-90 100 100)"
role="button"
tabIndex={0}
aria-label={`${segment.label} ${segment.count} 台,查看明细`}
onClick={() =>
open({
inventoryOperation: segment.key,
title: `${segment.label}车辆`,
})
}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
open({
inventoryOperation: segment.key,
title: `${segment.label}车辆`,
});
}
}}
>
<title>
{segment.label} {segment.count} 台 ·{" "}
{(proportion * 100).toFixed(1)}%
</title>
</circle>
);
})}
</svg>
<div className="analysis-donut-center">
<span>总库存</span>
<strong>{total.quantity.toLocaleString()}</strong>
<small>台</small>
</div>
</div>
<div className="analysis-legend">
<h3>运营状态</h3>
{segments.map((segment) => (
<button
key={segment.key}
disabled={!segment.count}
onClick={() =>
open({
inventoryOperation: segment.key,
title: `${segment.label}车辆`,
})
}
>
<span
className="analysis-dot"
style={{ background: segment.color }}
/>
<span>{segment.label}</span>
<strong>{segment.count}</strong>
<span>
{total.quantity
? ((segment.count / total.quantity) * 100).toFixed(1)
: "0.0"}
%
</span>
<ArrowUpRight aria-hidden="true" />
</button>
))}
<p>
{total.unclassified
? "运营状态按原始字段分别统计"
: "可运营 + 待运营 = 总库存"}
</p>
{total.unclassified > 0 && (
<p role="status">
存在旧状态,单独列示待核实,未计入可运营或待运营。
</p>
)}
</div>
</section>
<section className="analysis-workflow" aria-label="库存业务状态">
<div className="analysis-workflow-heading">
<h3>业务状态</h3>
<span>已包含在总库存内</span>
</div>
<button
className="analysis-workflow-total"
disabled={!total.abnormal}
onClick={() => open({ category: "Abnormal" })}
>
<span>
<AlertTriangle aria-hidden="true" />
异动
</span>
<strong>
{total.abnormal}
<small>台</small>
<ArrowUpRight aria-hidden="true" />
</strong>
</button>
<div className="analysis-abnormal-types">
{Object.entries(total.abnormalTypes).map(([type, count]) => (
<button
key={type}
onClick={() =>
open({
category: "Abnormal",
abnormalType: type,
title: `${type}异动车辆`,
})
}
>
{type}
<strong>{count}</strong>
</button>
))}
{!total.abnormal && <span>当前范围无异动车辆</span>}
</div>
<button
className="analysis-workflow-total"
disabled={!total.pending}
onClick={() => open({ category: "Pending" })}
>
<span>
<Truck aria-hidden="true" />
待交车
</span>
<strong>
{total.pending}
<small>台</small>
<ArrowUpRight aria-hidden="true" />
</strong>
</button>
</section>
</div>
<section className="analysis-variants" aria-label="型号及颜色明细">
<div className="analysis-variants-heading">
<div>
<h3>型号明细</h3>
<p>品牌 · 所属公司 · 颜色</p>
</div>
<Button variant="outline" onClick={() => open()}>
查看全部车辆
<ArrowUpRight aria-hidden="true" />
</Button>
</div>
<Input
aria-label="搜索型号明细"
placeholder="搜索型号、品牌、所属公司或颜色"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div className="analysis-variant-list">
{shown.map(([key, item]) => (
<button
className="analysis-variant"
key={key}
onClick={() =>
setSelection(
scopedInventorySelection(
{
rows: item.rows,
label: `${scope.label} / ${item.name} / ${item.brand}${item.operator ? ` / ${item.operator}` : ""} / ${item.color}`,
},
{ inventoryColor: item.color },
),
)
}
>
<div>
<strong>{item.name}</strong>
<div className="analysis-tags">
<span>{item.brand}</span>
{item.operator && <span>{item.operator}</span>}
<span>{item.color}</span>
</div>
</div>
<span className="analysis-variant-count">
{item.count}
<small>台</small>
<ArrowUpRight aria-hidden="true" />
</span>
</button>
))}
{!shown.length && (
<p className="matrix-empty">没有匹配的型号</p>
)}
</div>
</section>
</div>
<footer className="asset-detail-footer">
<span>点击数量查看当前范围的车辆明细</span>
<Button variant="outline" onClick={close}>
返回库存统计
</Button>
</footer>
</DialogContent>
</Dialog>
<VehicleDetails
selection={selection}
subject={subject}
close={() => setSelection(null)}
returnLabel="返回库存构成"
/>
</>
);
}
@@ -0,0 +1,475 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowRight,
ChevronRight,
MapPin,
Search,
Truck,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { RegionalInventoryStats } from "../types";
import { Picker, unique } from "./shared";
import { VehicleDetails } from "./VehicleDetails";
import {
INVENTORY_TYPES,
inventoryModel,
inventoryTotals,
scopedInventorySelection,
} from "./inventory-view";
import {
inventoryBrowseRows,
inventoryBrowseUrl,
inventoryModels,
inventoryPlaces,
inventoryTypeRows,
readInventoryBrowse,
type InventoryBrowseState,
} from "./inventory-explorer";
const fmt = (value: number) => value.toLocaleString();
const regionName = (value: string | null) => value || "未归属地区";
const cityName = (value: string | null) => value || "未归属城市";
export function InventoryExplorer({
inventory,
subject,
}: {
inventory: RegionalInventoryStats[];
subject: string | null;
}) {
const [state, setState] = useState(() =>
readInventoryBrowse(window.location.search),
);
const sectionRef = useRef<HTMLElement>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const positions = useRef(
new Map<
string,
{ scroll: [Element, number][]; y: number; focus?: string }
>(),
);
const currentUrl = useRef(window.location.href);
const locationKey = JSON.stringify([state.region, state.city]);
const lastLocation = useRef(locationKey);
const remember = () => {
const scroll: [Element, number][] = [];
let element = sectionRef.current?.parentElement;
while (element) {
if (element.scrollHeight > element.clientHeight)
scroll.push([element, element.scrollTop]);
element = element.parentElement;
}
positions.current.set(currentUrl.current, {
scroll,
y: window.scrollY,
focus: (document.activeElement as HTMLElement | null)?.dataset
.inventoryKey,
});
};
useEffect(() => {
const onBack = () => {
currentUrl.current = window.location.href;
setState(readInventoryBrowse(window.location.search));
};
window.addEventListener("popstate", onBack);
return () => window.removeEventListener("popstate", onBack);
}, []);
useEffect(() => {
if (lastLocation.current === locationKey) return;
lastLocation.current = locationKey;
const saved = positions.current.get(currentUrl.current);
if (saved) {
const target = saved.focus
? Array.from(
sectionRef.current?.querySelectorAll<HTMLElement>(
"[data-inventory-key]",
) || [],
).find((element) => element.dataset.inventoryKey === saved.focus)
: null;
(target || headingRef.current)?.focus({ preventScroll: true });
saved.scroll.forEach(([element, top]) => {
element.scrollTop = top;
});
window.scrollTo({ top: saved.y, behavior: "instant" });
} else {
headingRef.current?.focus({ preventScroll: true });
headingRef.current?.scrollIntoView({
block: "nearest",
behavior: "instant",
});
}
}, [locationKey]);
const navigate = (change: Partial<InventoryBrowseState>, replace = false) => {
remember();
const next = { ...state, ...change };
const url = inventoryBrowseUrl(window.location.href, next);
if (url === window.location.href) return;
if (replace) window.history.replaceState(window.history.state, "", url);
else
window.history.pushState(
{ ...window.history.state, inventoryParent: window.location.href },
"",
url,
);
currentUrl.current = url;
setState(next);
};
const goBack = () => {
const next =
state.model !== null
? { model: null }
: state.city !== null
? { city: null, model: null }
: { region: null, city: null, model: null };
const url = inventoryBrowseUrl(window.location.href, { ...state, ...next });
if (window.history.state?.inventoryParent === url) window.history.back();
else navigate(next);
};
const scoped = useMemo(
() => inventoryBrowseRows(inventory, state),
[inventory, state],
);
const rows = useMemo(
() => inventoryTypeRows(scoped, state.type),
[scoped, state.type],
);
const total = inventoryTotals(rows);
const places = inventoryPlaces(
scoped,
state.region === null ? "region" : "city",
state.type,
);
const models = useMemo(() => inventoryModels(rows), [rows]);
const chosenModel = models.find((model) => model.key === state.model);
const label = [
state.region === null ? "全部地区" : regionName(state.region),
state.city === null ? "" : cityName(state.city),
state.type || "全部车型",
]
.filter(Boolean)
.join(" / ");
const selection = useMemo(
() =>
chosenModel
? scopedInventorySelection({
rows: chosenModel.rows,
label: `${label} / ${chosenModel.brand || "未标注品牌"} / ${chosenModel.model}`,
})
: null,
[chosenModel, label],
);
const level = state.region === null ? 0 : state.city === null ? 1 : 2;
const hasFilters = Boolean(state.query || state.brand);
return (
<section
ref={sectionRef}
className="inventory-matrix inventory-explorer"
aria-label="库存找车"
>
<header className="finder-header">
<div>
<h2>库存找车</h2>
<p>选地区、选城市,快速找到对应车辆</p>
</div>
<MapPin aria-hidden="true" />
</header>
<ol className="finder-steps" aria-label="找车步骤">
{["选地区", "选城市", "查车辆"].map((title, index) => (
<li key={title} aria-current={index === level ? "step" : undefined}>
<span>{index + 1}</span>
{title}
{index < 2 && <ChevronRight aria-hidden="true" />}
</li>
))}
</ol>
<nav className="finder-breadcrumb" aria-label="库存位置">
{state.region === null ? (
<span aria-current="page">全部地区</span>
) : (
<button
onClick={() => navigate({ region: null, city: null, model: null })}
>
全部地区
</button>
)}
{state.region !== null && (
<>
<ChevronRight aria-hidden="true" />
{state.city === null ? (
<span aria-current="page">{regionName(state.region)}</span>
) : (
<button onClick={() => navigate({ city: null, model: null })}>
{regionName(state.region)}
</button>
)}
</>
)}
{state.city !== null && (
<>
<ChevronRight aria-hidden="true" />
<span aria-current="page">{cityName(state.city)}</span>
</>
)}
</nav>
<div className="finder-scope">
{level > 0 && (
<button
className="finder-back"
aria-label={level === 2 ? "返回城市列表" : "返回地区列表"}
onClick={goBack}
>
<ArrowLeft aria-hidden="true" />
</button>
)}
<div>
<h3 ref={headingRef} tabIndex={-1}>
{state.city !== null
? cityName(state.city)
: state.region !== null
? regionName(state.region)
: "地区库存"}
</h3>
<p>
{state.type || "全部车型"}
{hasFilters ? " · 已筛选" : ""} ·{" "}
{state.city !== null
? `${models.length} 种型号`
: `${places.filter((place) => place.total.quantity > 0).length} 个${level === 0 ? "地区" : "城市"}有库存`}
</p>
</div>
<div className="finder-scope-total" aria-live="polite">
<strong>{fmt(total.quantity)}</strong>
<span>库存 / 台</span>
</div>
</div>
<div className="finder-type-heading">
<strong>找什么车型</strong>
<span>数量为当前范围库存</span>
</div>
<div className="matrix-mobile-types finder-types" aria-label="库存车型">
{["", ...INVENTORY_TYPES].map((type) => (
<button
key={type}
aria-pressed={state.type === type}
onClick={() => navigate({ type, model: null })}
>
{type || "全部车型"}
<span>
{fmt(inventoryTotals(inventoryTypeRows(scoped, type)).quantity)}
</span>
</button>
))}
</div>
<div className="finder-search-row">
<label className="matrix-search">
<Search aria-hidden="true" />
<Input
value={state.query}
placeholder={
level === 0
? "搜索地区、城市、品牌或型号"
: "搜索当前范围的城市、品牌或型号"
}
aria-label="搜索当前范围库存"
onChange={(event) =>
navigate({ query: event.target.value, model: null }, true)
}
/>
{state.query && (
<button
aria-label="清空库存搜索"
onClick={() => navigate({ query: "", model: null }, true)}
>
<X aria-hidden="true" />
</button>
)}
</label>
<Picker
label="品牌"
value={state.brand}
values={unique(inventory.map((row) => row.brand))}
change={(brand) => navigate({ brand, model: null })}
/>
</div>
<div className="finder-results-heading">
<strong>
{level === 0
? "选择地区"
: level === 1
? "选择城市"
: "选择型号查看车辆"}
</strong>
{hasFilters ? (
<button
onClick={() => navigate({ query: "", brand: "", model: null })}
>
清除筛选
</button>
) : (
<span>{level < 2 ? "按库存从多到少" : "点击进入车辆明细"}</span>
)}
</div>
{total.quantity === 0 && (
<div className="finder-empty" role="status">
<Truck aria-hidden="true" />
<strong>
{hasFilters
? "当前范围没有匹配车辆"
: state.type
? `当前范围暂无${state.type}库存`
: "当前范围暂无库存"}
</strong>
<p>
{hasFilters
? "试试清除搜索或品牌条件,也可以返回上级查找。"
: "可切换车型,或返回上级查看其他地区、城市。"}
</p>
{(state.type || hasFilters) && (
<Button
variant="outline"
onClick={() =>
navigate({ type: "", query: "", brand: "", model: null })
}
>
查看当前范围全部库存
</Button>
)}
{level > 0 && (
<Button variant="ghost" onClick={goBack}>
返回{level === 2 ? "城市" : "地区"}列表
</Button>
)}
</div>
)}
{level < 2 ? (
<div className="finder-list">
{places.map((place) => (
<button
key={place.key}
className="finder-place"
data-inventory-key={place.key}
disabled={!place.total.quantity}
aria-label={`${place.name},${state.type || "全部车型"}库存 ${place.total.quantity} 台,${level === 0 ? "选择城市" : "查看车型"}`}
onClick={() =>
navigate(
level === 0
? { region: place.value, city: null, model: null }
: { city: place.value, model: null },
)
}
>
<span className="finder-place-top">
<strong>{place.name}</strong>
<span className="finder-place-count">
{fmt(place.total.quantity)}
<small>台</small>
{place.total.quantity > 0 && (
<ChevronRight aria-hidden="true" />
)}
</span>
</span>
{place.total.quantity > 0 ? (
<>
<span className="finder-place-status">
{level === 0 && <span>{place.cities} 个城市有库存</span>}
<span>可运营 {fmt(place.total.available)}</span>
<span>待运营 {fmt(place.total.awaiting)}</span>
</span>
<span className="finder-type-tags">
{(state.type
? place.types.filter((item) => item.name === state.type)
: place.types
).map((item) => (
<span key={item.name}>
{item.name}
<b>{fmt(item.quantity)}</b>
</span>
))}
</span>
</>
) : (
<span className="finder-place-status">
{state.type ? "该车型暂无库存" : "暂无库存"}
</span>
)}
</button>
))}
</div>
) : (
<div className="finder-list">
{models.map((model) => (
<button
key={model.key}
className="finder-place finder-model"
data-inventory-key={model.key}
onClick={() => navigate({ model: model.key })}
>
<span className="finder-place-top">
<strong>{inventoryModel(model.model, model.brand).name}</strong>
<span className="finder-place-count">
{fmt(model.total.quantity)}
<small>台</small>
</span>
</span>
<span className="finder-model-meta">
{model.brand || "未标注品牌"} · {model.type}
{inventoryModel(model.model, model.brand).operator &&
` · ${inventoryModel(model.model, model.brand).operator}`}
</span>
<span className="finder-place-status">
<span>可运营 {fmt(model.total.available)}</span>
<span>待运营 {fmt(model.total.awaiting)}</span>
{model.total.unclassified > 0 && (
<span>待核实 {fmt(model.total.unclassified)}</span>
)}
</span>
<span className="finder-model-footer">
<span>
{[
model.total.pending
? `待交车 ${fmt(model.total.pending)}`
: "",
model.total.abnormal
? `异动 ${fmt(model.total.abnormal)}`
: "",
]
.filter(Boolean)
.join(" · ")}
</span>
<span>
查看车辆
<ArrowRight aria-hidden="true" />
</span>
</span>
</button>
))}
</div>
)}
{state.model !== null && !chosenModel && (
<div className="finder-empty" role="status">
<strong>该型号已不在当前库存范围</strong>
<Button variant="outline" onClick={goBack}>
返回选择型号
</Button>
</div>
)}
<footer className="matrix-footer">
<span>库存包含待交车、异动车辆,不代表均可立即交付。</span>
<span>「其他」包含挂车及未单列车型。</span>
</footer>
<VehicleDetails
selection={selection}
subject={subject}
close={goBack}
returnLabel="返回找车结果"
/>
</section>
);
}
@@ -0,0 +1,460 @@
import { Fragment, useMemo, useState } from "react";
import {
ChevronDown,
ChevronRight,
ChevronsDownUp,
ListFilter,
Search,
X,
ArrowUpRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { RegionalInventoryStats } from "../types";
import { Picker, unique, useMobile } from "./shared";
import { ExportButton } from "./export";
import { InventoryExplorer } from "./InventoryExplorer";
import {
groupInventory,
INVENTORY_TYPES,
inventoryColumn,
inventoryTotals,
type InventoryScope,
} from "./inventory-view";
type Inspect = (scope: InventoryScope) => void;
export function InventoryNumber({
rows,
label,
inspect,
}: {
rows: RegionalInventoryStats[];
label: string;
inspect: Inspect;
}) {
const total = inventoryTotals(rows);
if (!total.quantity) return <span className="matrix-zero">0</span>;
return (
<div className="matrix-number">
<button
className="matrix-value"
onClick={() => inspect({ rows, label })}
aria-label={`${label},库存 ${total.quantity} 台,查看构成`}
>
{total.quantity.toLocaleString()}
<ArrowUpRight aria-hidden="true" />
</button>
{(total.pending > 0 || total.abnormal > 0) && (
<div className="matrix-secondary">
{total.pending > 0 && (
<button
onClick={() =>
inspect({ rows, label, initialCategory: "Pending" })
}
>
待交车 {total.pending}
</button>
)}
{total.abnormal > 0 && (
<button
className="matrix-warning"
onClick={() =>
inspect({ rows, label, initialCategory: "Abnormal" })
}
>
异动 {total.abnormal}
</button>
)}
</div>
)}
</div>
);
}
export function InventorySection({
inventory,
inspect,
subject,
}: {
subject: string | null;
inventory: RegionalInventoryStats[];
inspect: Inspect;
}) {
const mobile = useMobile();
const [query, setQuery] = useState("");
const [filters, setFilters] = useState({ region: "", city: "", brand: "" });
const [filterOpen, setFilterOpen] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [expandedType, setExpandedType] = useState("");
const filtered = useMemo(
() =>
inventory.filter(
(row) =>
Object.entries(filters).every(
([key, value]) =>
!value || row[key as keyof typeof filters] === value,
) &&
(!query.trim() ||
[
row.region,
row.city,
row.brand,
row.type,
row.model,
inventoryColumn(row.type),
]
.join(" ")
.toLowerCase()
.includes(query.trim().toLowerCase())),
),
[inventory, filters, query],
);
const rows = filtered;
const groups = groupInventory(rows, "region");
const brands = unique(
filtered
.filter((row) => inventoryColumn(row.type) === expandedType)
.map((row) => row.brand),
);
const hasBrandColumns = Boolean(expandedType) && brands.length > 0;
const columns = INVENTORY_TYPES.flatMap((type) => [
{
key: type,
title: expandedType === type && brands.length ? "小计" : type,
type,
brand: "",
},
...(expandedType === type
? brands.map((brand) => ({
key: `${type}-${brand}`,
title: brand,
type,
brand,
}))
: []),
]);
const activeCount = Object.values(filters).filter(Boolean).length;
const total = inventoryTotals(rows);
const toggleRegion = (region: string) =>
setExpanded((old) => {
const next = new Set(old);
if (next.has(region)) next.delete(region);
else next.add(region);
return next;
});
const cellRows = (
records: RegionalInventoryStats[],
type: string,
brand: string,
) =>
records.filter(
(row) =>
inventoryColumn(row.type) === type && (!brand || row.brand === brand),
);
const renderCells = (records: RegionalInventoryStats[], label: string) => (
<>
<td className="matrix-total">
<InventoryNumber rows={records} label={label} inspect={inspect} />
</td>
{columns.map((col) => (
<td
key={col.key}
className={[
col.brand ? "matrix-brand-cell" : "",
expandedType === col.type ? "matrix-selected-column" : "",
]
.filter(Boolean)
.join(" ")}
>
<InventoryNumber
rows={cellRows(records, col.type, col.brand)}
label={[label, col.type, col.brand].filter(Boolean).join(" / ")}
inspect={inspect}
/>
</td>
))}
</>
);
if (mobile)
return <InventoryExplorer inventory={inventory} subject={subject} />;
return (
<section className="inventory-matrix" aria-label="库存统计">
<header className="matrix-heading">
<div>
<h2>库存统计</h2>
<p>地区 × 车型总览,展开城市核对库存</p>
</div>
<div className="matrix-heading-actions">
<div className="matrix-header-total">
<span>{query || activeCount ? "筛选库存" : "总库存"}</span>
<InventoryNumber rows={rows} label="当前库存" inspect={inspect} />
</div>
<Button
variant="outline"
aria-expanded={filterOpen}
onClick={() => setFilterOpen(!filterOpen)}
>
<ListFilter aria-hidden="true" />
筛选{activeCount ? ` ${activeCount}` : ""}
</Button>
</div>
</header>
<div className="matrix-toolbar">
<label className="matrix-search">
<Search aria-hidden="true" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索区域、城市、品牌或型号"
aria-label="搜索库存"
/>
{query && (
<button onClick={() => setQuery("")} aria-label="清空库存搜索">
<X />
</button>
)}
</label>
<div className="matrix-tools">
<Button
variant="ghost"
onClick={() =>
setExpanded(
expanded.size
? new Set()
: new Set(groups.map((group) => group.name)),
)
}
>
<ChevronsDownUp aria-hidden="true" />
{expanded.size ? "收起区域" : "展开区域"}
</Button>
<ExportButton
rows={rows}
columns={[
{ key: "region", label: "区域" },
{ key: "city", label: "城市" },
{ key: "brand", label: "品牌" },
{
key: "type",
label: "车型",
format: (value) => inventoryColumn(String(value)),
},
{ key: "model", label: "原始型号" },
{ key: "quantity", label: "库存" },
{ key: "available", label: "可运营" },
{ key: "awaiting", label: "待运营" },
{ key: "abnormal", label: "异动" },
{ key: "pending", label: "待交车" },
]}
filename="库存统计"
sheetName="库存"
/>
</div>
</div>
{filterOpen && (
<div className="matrix-filters">
<Picker
label="区域"
value={filters.region}
values={unique(inventory.map((row) => row.region))}
change={(region) =>
setFilters((old) => ({ ...old, region, city: "" }))
}
/>
<Picker
label="城市"
value={filters.city}
values={unique(
inventory
.filter(
(row) => !filters.region || row.region === filters.region,
)
.map((row) => row.city),
)}
change={(city) => setFilters((old) => ({ ...old, city }))}
/>
<Picker
label="品牌"
value={filters.brand}
values={unique(inventory.map((row) => row.brand))}
change={(brand) => setFilters((old) => ({ ...old, brand }))}
/>
<Button
variant="ghost"
onClick={() => {
setFilters({ region: "", city: "", brand: "" });
setQuery("");
}}
>
重置筛选
</Button>
</div>
)}
{activeCount > 0 && (
<div className="matrix-filter-summary">
{Object.values(filters).filter(Boolean).join(" / ")}
<button
onClick={() => setFilters({ region: "", city: "", brand: "" })}
>
清除条件
</button>
</div>
)}
<div className="matrix-type-overview" aria-label="各车型库存总览">
{INVENTORY_TYPES.map((type) => {
const counts = inventoryTotals(cellRows(rows, type, ""));
return (
<button
key={type}
aria-pressed={expandedType === type}
onClick={() => setExpandedType(expandedType === type ? "" : type)}
>
<span>
{type}
<ChevronDown aria-hidden="true" />
</span>
<strong>
{counts.quantity.toLocaleString()}
<small>台</small>
</strong>
<span>可运营 {counts.available.toLocaleString()}</span>
</button>
);
})}
</div>
<div className="matrix-reading-guide">
<span>地区合计与车型合计均为当前筛选范围</span>
<span>点击车型展开品牌 · 点击数字查看明细</span>
</div>
{!rows.length ? (
<div className="matrix-empty">
<strong>暂无匹配库存</strong>
<p>尝试切换车型或调整筛选条件</p>
<Button
variant="outline"
onClick={() => {
setQuery("");
setFilters({ region: "", city: "", brand: "" });
}}
>
查看全部库存
</Button>
</div>
) : (
<div
className="matrix-scroll"
tabIndex={0}
aria-label="库存矩阵,可横向滚动"
>
<table className="matrix-table">
<caption className="sr-only">
区域展开城市,车型展开品牌,点击库存数量查看构成与车辆明细
</caption>
<thead>
<tr>
<th
rowSpan={hasBrandColumns ? 2 : 1}
scope="col"
className="matrix-geography"
>
区域 / 城市
</th>
<th
rowSpan={hasBrandColumns ? 2 : 1}
scope="col"
className="matrix-total"
>
总库存
</th>
{INVENTORY_TYPES.map((type) => (
<th
key={type}
colSpan={expandedType === type ? 1 + brands.length : 1}
rowSpan={expandedType === type || !hasBrandColumns ? 1 : 2}
scope="colgroup"
>
<button
className="matrix-column-toggle"
aria-expanded={expandedType === type}
onClick={() =>
setExpandedType(expandedType === type ? "" : type)
}
>
{type}
{expandedType === type ? (
<ChevronDown />
) : (
<ChevronRight />
)}
</button>
</th>
))}
</tr>
{hasBrandColumns && (
<tr>
<th scope="col">小计</th>
{brands.map((brand) => (
<th key={brand} scope="col">
{brand}
</th>
))}
</tr>
)}
</thead>
<tbody>
{groups.map((group) => (
<Fragment key={group.name}>
<tr className="matrix-region-row">
<th scope="row" className="matrix-geography">
<button
className="matrix-expand"
aria-expanded={expanded.has(group.name)}
onClick={() => toggleRegion(group.name)}
>
{expanded.has(group.name) ? (
<ChevronDown />
) : (
<ChevronRight />
)}
{group.name}
<small>{group.cities.length} 城市</small>
</button>
</th>
{renderCells(
group.cities.flatMap((city) => city.rows),
group.name,
)}
</tr>
{expanded.has(group.name) &&
group.cities.map((city) => (
<tr key={city.key} className="matrix-city-row">
<th scope="row" className="matrix-geography">
<span>{city.name}</span>
</th>
{renderCells(city.rows, `${group.name} / ${city.name}`)}
</tr>
))}
</Fragment>
))}
</tbody>
<tfoot>
<tr>
<th scope="row" className="matrix-geography">
当前范围合计
</th>
{renderCells(rows, "当前库存合计")}
</tr>
</tfoot>
</table>
</div>
)}
<footer className="matrix-footer">
<span>
{groups.length} 个区域 ·{" "}
{groups.reduce((sum, group) => sum + group.cities.length, 0)} 个城市 ·{" "}
{total.quantity} 台
</span>
<span>异动、待交车已包含在库存中 ·「其他」包含挂车及未单列车型</span>
</footer>
</section>
);
}
@@ -0,0 +1,333 @@
import { inventoryColor } from "../../../shared/assets/inventory";
import { matchesInventorySelection, inventoryModel } from "./inventory-view";
import { ArrowLeft, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useEffect, useRef, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import {
fetchVehicleList,
fetchWeeklyDetail,
type WeeklyDetailItem,
} from "../api";
import { buildVehicleModalRequest, type VehicleModalSelection } from "../model";
import type { VehicleListItem } from "../types";
import { DataGrid, type Column } from "./DataGrid";
import { ExportButton } from "./export";
import { Failure, FilterFields, PrivateText, unique } from "./shared";
const titles = {
Inventory: "库存车辆",
Operating: "运营车辆",
Abnormal: "异动车辆",
Pending: "待交车车辆",
Delivered: "本周交车",
Returned: "本周还车",
Replaced: "本周替换交车",
};
export function VehicleDetails({
selection,
subject,
close,
returnLabel = "返回统计",
}: {
selection: VehicleModalSelection | null;
subject: string | null;
close: () => void;
returnLabel?: string;
}) {
const returnFocus = useRef<HTMLElement | null>(null);
const [vehicles, setVehicles] = useState<VehicleListItem[]>([]);
const [weekly, setWeekly] = useState<WeeklyDetailItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
const [filters, setFilters] = useState({
model: "",
brandLabel: "",
location: "",
});
useEffect(() => {
let active = true;
setFilters({ model: "", brandLabel: "", location: "" });
setVehicles([]);
setWeekly([]);
setError("");
if (!selection) return;
setLoading(true);
const request = buildVehicleModalRequest(selection, subject);
const run = async () => {
try {
if (request.kind === "weekly") {
const rows = await fetchWeeklyDetail(request.type, {
...request.filters,
subject,
});
if (active) setWeekly(rows);
} else {
const rows = await fetchVehicleList(request.params);
if (active)
setVehicles(
rows.filter((row) => matchesInventorySelection(row, selection)),
);
}
} catch (e) {
if (active) setError(e instanceof Error ? e.message : "加载失败");
} finally {
if (active) setLoading(false);
}
};
void run();
return () => {
active = false;
};
}, [selection, subject, retry]);
const rows = vehicles
.filter((r) =>
Object.entries(filters).every(
([k, v]) => !v || r[k as keyof VehicleListItem] === v,
),
)
.map((r) => ({
...r,
color: inventoryColor(r.color),
displayModel: inventoryModel(r.model, r.brandLabel || "").name,
operator: inventoryModel(r.model, r.brandLabel || "").operator || "—",
pendingContract: r.pendingDelivery?.contractNo || "—",
pendingCustomer: r.pendingDelivery?.customerName || "—",
moveType: r.abnormalMove?.type || "—",
work: r.abnormalMove?.work || "—",
target: r.abnormalMove?.targetCompletionTime || "—",
actual: r.abnormalMove?.actualCompletionTime || "—",
initiating: r.abnormalMove?.initiatingDepartment || "—",
executing: r.abnormalMove?.executingDepartment || "—",
responsible: r.abnormalMove?.responsiblePerson || "—",
driver: r.abnormalMove?.driver || "—",
}));
type Row = (typeof rows)[number];
const columns: Column<Row>[] = [
{ key: "plateNumber", label: "车牌", private: true, width: 140 },
{ key: "brandLabel", label: "品牌" },
{ key: "type", label: "车型" },
{ key: "displayModel", label: "型号", width: 160 },
{ key: "operator", label: "所属公司" },
{ key: "color", label: "颜色" },
{ key: "location", label: "所在地" },
];
if (selection?.category === "Pending")
columns.push(
{
key: "pendingContract",
primary: true,
label: "待交车合同",
width: 180,
},
{
key: "pendingCustomer",
primary: true,
label: "客户(已脱敏)",
width: 180,
private: true,
},
);
if (selection?.category === "Abnormal")
columns.push(
...([
{ key: "moveType", primary: true, label: "异动类型" },
{ key: "target", primary: true, label: "预计结束时间", width: 180 },
{ key: "work", label: "具体工作", width: 220 },
{ key: "initiating", label: "发起部门" },
{ key: "executing", label: "执行部门" },
{ key: "responsible", label: "负责人", private: true },
{ key: "driver", label: "操作司机", private: true },
{ key: "actual", label: "实际完成时间", width: 180 },
] as Column<Row>[]),
);
columns.push(
{
key: "operationStatus",
label: "运营状态",
primary: true,
format: (value) =>
({ "1": "租赁", "2": "自营", "3": "可运营", "4": "待运营" })[
String(value)
] || String(value || "未标注"),
},
{
key: "status",
label: "业务状态",
primary: true,
format: (value) =>
({
Inventory: "库存",
Operating: "运营",
Abnormal: "异动",
Pending: "待交车",
})[String(value)] || String(value),
},
{ key: "departmentName", label: "部门" },
{ key: "customerManager", label: "客户经理", private: true },
{ key: "vin", label: "车架号", private: true, width: 200 },
);
const isWeekly =
selection &&
["Delivered", "Returned", "Replaced"].includes(selection.category || "");
const weeklyColumns: Column<WeeklyDetailItem>[] = [
{ key: "plate_number", label: "车牌", private: true },
{ key: "handover_date", label: "签章日期", width: 180 },
{ key: "contract_type", label: "合同类型" },
{ key: "customer_name", label: "客户", private: true },
];
const title =
selection?.title ||
(selection?.category ? titles[selection.category] : "车辆明细");
const context = [
["城市 / 区域", selection?.location],
["品牌", selection?.brand],
["车型", selection?.type || selection?.vehicleType],
["型号", selection?.model],
["批次", selection?.batch],
["部门", selection?.department],
].filter(([, value]) => value && value !== "All");
return (
<Dialog open={!!selection} onOpenChange={(v) => !v && close()}>
<DialogContent
className="asset-ui asset-detail"
showCloseButton={false}
onOpenAutoFocus={() => {
returnFocus.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
}}
onCloseAutoFocus={(event) => {
if (returnFocus.current?.isConnected) {
event.preventDefault();
returnFocus.current.focus({ preventScroll: true });
}
}}
>
<DialogHeader className="asset-detail-header">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={close}
aria-label={returnLabel}
>
<ArrowLeft />
</Button>
<div className="min-w-0 flex-1">
<p className="mb-1 text-xs text-[var(--label-secondary)]">
资产管理 /{" "}
{selection?.source === "department"
? "部门运营"
: selection?.source === "region"
? "区域分布"
: selection?.source === "customer"
? "客户资产"
: "资产统计"}
</p>
<DialogTitle className="text-lg font-semibold">
{title}
</DialogTitle>
</div>
<Button
variant="ghost"
size="icon"
className="hidden md:inline-flex"
onClick={close}
aria-label="关闭车辆明细"
>
<X />
</Button>
</div>
<DialogDescription>
{subject || "全部公司"} ·{" "}
{loading
? "正在加载车辆…"
: `${isWeekly ? weekly.length : vehicles.length} 条记录`}
</DialogDescription>
{selection?.scopeLabel && (
<p className="text-sm">{selection.scopeLabel}</p>
)}
{context.length > 0 && (
<div className="asset-context" aria-label="当前下钻范围">
{context.map(([label, value]) => (
<span key={label}>
{label}:{value}
</span>
))}
</div>
)}
{selection?.customer && (
<p className="text-xs text-[var(--label-secondary)]">
客户:<PrivateText>{selection.customer}</PrivateText>
</p>
)}
{selection?.manager && (
<p className="text-xs text-[var(--label-secondary)]">
负责人:<PrivateText>{selection.manager}</PrivateText>
</p>
)}
</DialogHeader>
<div className="asset-detail-body" aria-busy={loading}>
{error ? (
<Failure message={error} retry={() => setRetry((n) => n + 1)} />
) : loading ? (
<Skeleton className="h-64" />
) : isWeekly ? (
<>
<ExportButton
rows={weekly}
columns={weeklyColumns}
filename="交还车车辆"
sheetName="交还车车辆"
/>
<DataGrid
rows={weekly}
label="交还车车辆"
columns={weeklyColumns}
/>
</>
) : (
<>
<FilterFields
fields={(["model", "brandLabel", "location"] as const).map(
(key, i) => ({
label: ["型号", "品牌", "所在地"][i],
value: filters[key],
values: unique(vehicles.map((r) => r[key])),
change: (value) =>
setFilters((f) => ({ ...f, [key]: value })),
}),
)}
onReset={() =>
setFilters({ model: "", brandLabel: "", location: "" })
}
/>
<ExportButton
rows={rows}
columns={columns}
filename="车辆明细"
sheetName="车辆"
/>
<DataGrid rows={rows} columns={columns} label="车辆" />
</>
)}
</div>
<footer className="asset-detail-footer">
<span>关闭后返回原来的筛选与浏览位置</span>
<Button variant="outline" onClick={close}>
{returnLabel}
</Button>
</footer>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
import { Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Column } from "./DataGrid";
function cellValue<T extends object>(row: T, col: Column<T>): string | number {
const raw = row[col.key];
if (col.format) return col.format(raw, row);
return (raw as string | number) ?? "";
}
/** 按表格列定义导出 xlsx;xlsx 按需加载,避免进入首屏包。 */
export async function exportToXlsx<T extends object>(
rows: T[],
columns: Column<T>[],
filename: string,
sheetName = "明细",
): Promise<void> {
const XLSX = await import("xlsx");
const sheet = XLSX.utils.json_to_sheet(
rows.map((row) =>
Object.fromEntries(columns.map((col) => [col.label, cellValue(row, col)])),
),
);
const book = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(book, sheet, sheetName);
XLSX.writeFile(book, `${filename}.xlsx`);
}
export function ExportButton<T extends object>({
rows,
columns,
filename,
sheetName,
label = "导出",
}: {
rows: T[];
columns: Column<T>[];
filename: string;
sheetName?: string;
label?: string;
}) {
return (
<Button
variant="outline"
size="sm"
className="min-h-9 w-fit md:min-h-7"
disabled={!rows.length}
onClick={() => void exportToXlsx(rows, columns, filename, sheetName)}
>
<Download data-icon="inline-start" />
{label}
</Button>
);
}
@@ -0,0 +1,181 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { RegionalInventoryStats } from "../types";
import {
inventoryBrowseRows,
inventoryBrowseUrl,
inventoryModels,
inventoryPlaces,
inventoryTypeRows,
readInventoryBrowse,
} from "./inventory-explorer";
import {
INVENTORY_TYPES,
inventoryTotals,
scopedInventorySelection,
} from "./inventory-view";
import { inventoryKey } from "../../../shared/assets/inventory";
const make = (
changes: Partial<RegionalInventoryStats> = {},
): RegionalInventoryStats => ({
region: "华东",
city: "嘉兴市",
brand: "现代",
type: "4.5T冷链",
model: "冷链(公司甲)",
batch: "批次甲",
quantity: 5,
available: 3,
awaiting: 2,
pending: 1,
abnormal: 1,
...changes,
});
test("region, city and type partitions reconcile without counting pending/abnormal twice", () => {
const rows = [
make(),
make({ city: "杭州市", quantity: 3 }),
make({ region: "华南", city: "广州市", type: "49T", quantity: 7 }),
make({ type: "挂车", quantity: 2 }),
];
const total = inventoryTotals(rows).quantity;
assert.equal(total, 17);
assert.equal(
inventoryPlaces(rows, "region", "").reduce(
(n, place) => n + place.total.quantity,
0,
),
total,
);
assert.equal(
INVENTORY_TYPES.reduce(
(n, type) => n + inventoryTotals(inventoryTypeRows(rows, type)).quantity,
0,
),
total,
);
for (const region of inventoryPlaces(rows, "region", "")) {
assert.equal(
inventoryPlaces(region.rows, "city", "").reduce(
(n, city) => n + city.total.quantity,
0,
),
region.total.quantity,
);
}
});
test("type selection keeps zero-stock locations visible and never uses other-type totals", () => {
const places = inventoryPlaces(
[
make(),
make({ region: "华南", city: "广州市", type: "49T", quantity: 7 }),
],
"region",
"49T",
);
assert.deepEqual(
places.map((place) => [place.name, place.total.quantity]),
[
["华南", 7],
["华东", 0],
],
);
assert.equal(places[1].rows.length, 0);
});
test("city drill-down intersects geography, brand, query and type before opening exact detail keys", () => {
const wanted = make();
const rows = [
wanted,
make({ region: "华南" }),
make({ city: "杭州市" }),
make({ brand: "另一品牌" }),
make({ model: "普货", type: "4.5T普货" }),
];
const state = {
...readInventoryBrowse(""),
region: "华东",
city: "嘉兴市",
brand: "现代",
query: "冷链",
type: "4.5T冷链",
};
const filtered = inventoryTypeRows(
inventoryBrowseRows(rows, state),
state.type,
);
assert.deepEqual(filtered, [wanted]);
const model = inventoryModels(filtered)[0];
assert.deepEqual(
scopedInventorySelection({ rows: model.rows, label: "测试范围" })
.inventoryKeys,
[inventoryKey(wanted)],
);
});
test("missing geography and duplicate city/model names retain exact identity", () => {
const rows = [
make({ region: "", city: "" }),
make({ city: "" }),
make({ region: "华南", city: "" }),
];
assert.equal(inventoryPlaces(rows, "city", "").length, 3);
assert.deepEqual(
inventoryBrowseRows(rows, {
...readInventoryBrowse(""),
region: "",
city: "",
}),
[rows[0]],
);
const models = inventoryModels([
make({ type: "挂车" }),
make({ type: "35T" }),
make({ brand: "另一品牌" }),
]);
assert.equal(models.length, 3);
assert.equal(new Set(models.map((model) => model.key)).size, 3);
});
test("deep links round-trip filters, empty geography and model identity without changing the host route", () => {
const state = {
region: "",
city: "",
type: "其他",
model: '["现代","挂车","A/B"]',
query: "A/B & C",
brand: "现代",
};
const url = new URL(
inventoryBrowseUrl(
"https://example.test/asset?existing=yes#assets/overview",
state,
),
);
assert.deepEqual(readInventoryBrowse(url.search), state);
assert.equal(url.hash, "#assets/overview");
assert.equal(url.pathname, "/asset");
assert.equal(url.searchParams.get("existing"), "yes");
const cleared = new URL(
inventoryBrowseUrl(url.href, readInventoryBrowse("")),
);
assert.equal(cleared.search, "?existing=yes");
});
test("orphan child links and invalid type values cannot select an unrelated scope", () => {
assert.deepEqual(
readInventoryBrowse("?stockCity=嘉兴市&stockModel=x&stockType=invalid"),
readInventoryBrowse(""),
);
const state = readInventoryBrowse("?stockRegion=华东&stockModel=x");
assert.equal(state.model, null);
assert.equal(inventoryModels([]).length, 0);
assert.equal(
inventoryBrowseRows([make()], { ...state, query: "no matching model" })
.length,
0,
);
});
@@ -0,0 +1,150 @@
import type { RegionalInventoryStats } from "../types";
import {
INVENTORY_TYPES,
inventoryColumn,
inventoryTotals,
} from "./inventory-view";
export interface InventoryBrowseState {
region: string | null;
city: string | null;
type: string;
model: string | null;
query: string;
brand: string;
}
export function readInventoryBrowse(search: string): InventoryBrowseState {
const params = new URLSearchParams(search);
const region = params.get("stockRegion");
const city = region === null ? null : params.get("stockCity");
const type = params.get("stockType") || "";
return {
region,
city,
type: INVENTORY_TYPES.includes(type as (typeof INVENTORY_TYPES)[number])
? type
: "",
model: city === null ? null : params.get("stockModel"),
query: params.get("stockQuery") || "",
brand: params.get("stockBrand") || "",
};
}
/** Keep the application's route and unrelated URL parameters intact. */
export function inventoryBrowseUrl(href: string, state: InventoryBrowseState) {
const url = new URL(href);
for (const [key, value] of Object.entries({
stockRegion: state.region,
stockCity: state.region === null ? null : state.city,
stockType: state.type || null,
stockModel:
state.region === null || state.city === null ? null : state.model,
stockQuery: state.query || null,
stockBrand: state.brand || null,
})) {
if (value === null) url.searchParams.delete(key);
else url.searchParams.set(key, value);
}
return url.href;
}
export function inventoryBrowseRows(
rows: RegionalInventoryStats[],
state: InventoryBrowseState,
) {
const query = state.query.trim().toLocaleLowerCase();
return rows.filter(
(row) =>
(state.region === null || row.region === state.region) &&
(state.city === null || row.city === state.city) &&
(!state.brand || row.brand === state.brand) &&
(!query ||
[
row.region,
row.city,
row.brand,
row.type,
row.model,
inventoryColumn(row.type),
]
.join(" ")
.toLocaleLowerCase()
.includes(query)),
);
}
export function inventoryTypeRows(
rows: RegionalInventoryStats[],
type: string,
) {
return type ? rows.filter((row) => inventoryColumn(row.type) === type) : rows;
}
/** Build places before applying the type filter so no-stock places remain explicit. */
export function inventoryPlaces(
rows: RegionalInventoryStats[],
level: "region" | "city",
type: string,
) {
const groups = new Map<string, RegionalInventoryStats[]>();
for (const row of rows) {
const key = JSON.stringify(
level === "region" ? [row.region] : [row.region, row.city],
);
const group = groups.get(key) || [];
group.push(row);
groups.set(key, group);
}
return [...groups]
.map(([key, records]) => {
const selected = inventoryTypeRows(records, type);
return {
key,
value: records[0][level],
name:
records[0][level] ||
(level === "region" ? "未归属地区" : "未归属城市"),
rows: selected,
total: inventoryTotals(selected),
cities: new Set(
selected.filter((row) => row.quantity > 0).map((row) => row.city),
).size,
types: INVENTORY_TYPES.map((name) => ({
name,
quantity: inventoryTotals(inventoryTypeRows(records, name)).quantity,
})).filter((item) => item.quantity > 0),
};
})
.sort(
(a, b) =>
b.total.quantity - a.total.quantity ||
a.name.localeCompare(b.name, "zh-CN"),
);
}
/** Keep the exact type/model/brand identity even when displayed in the Other column. */
export function inventoryModels(rows: RegionalInventoryStats[]) {
const groups = new Map<string, RegionalInventoryStats[]>();
for (const row of rows) {
const key = JSON.stringify([row.brand, row.type, row.model]);
const group = groups.get(key) || [];
group.push(row);
groups.set(key, group);
}
return [...groups]
.map(([key, records]) => ({
key,
rows: records,
brand: records[0].brand,
model: records[0].model,
type: records[0].type,
total: inventoryTotals(records),
}))
.filter((group) => group.total.quantity > 0)
.sort(
(a, b) =>
b.total.quantity - a.total.quantity ||
a.model.localeCompare(b.model, "zh-CN"),
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,203 @@
import {
inventoryKey,
inventoryOperation,
inventoryColor,
} from "../../../shared/assets/inventory";
import {
INVENTORY_TYPES,
inventoryColumn,
inventoryTotals,
inventoryModel,
scopedInventorySelection,
matchesInventorySelection,
} from "./inventory-view";
import type { VehicleListItem } from "../types";
import test from "node:test";
import assert from "node:assert/strict";
import { groupInventory, inventorySelection } from "./inventory-view";
import { buildVehicleModalRequest } from "../model";
import type { RegionalInventoryStats } from "../types";
const make = (
change: Partial<RegionalInventoryStats> = {},
): RegionalInventoryStats => ({
region: "华东",
city: "嘉兴市",
brand: "明威",
type: "挂车",
model: "挂车",
batch: "挂车",
quantity: 2,
abnormal: 1,
pending: 1,
...change,
});
test("inventory grouping preserves totals and does not double-count abnormal/pending subsets", () => {
const rows = [
make(),
make({ brand: "万风", quantity: 1 }),
make({ region: "华南", city: "广州市", quantity: 4 }),
];
const original = JSON.stringify(rows);
const groups = groupInventory(rows, "region");
assert.deepEqual(
groups.map((group) => [group.name, group.total]),
[
["华南", 4],
["华东", 3],
],
);
assert.equal(groups[1].cities[0].rows.length, 2);
assert.equal(
groups.reduce((sum, group) => sum + group.total, 0),
7,
);
assert.equal(JSON.stringify(rows), original);
});
test("type grouping keeps same-name cities in different regions separate", () => {
const grouped = groupInventory(
[make(), make({ region: "华南", brand: "万风" })],
"type",
);
assert.equal(grouped.length, 1);
assert.equal(grouped[0].cities.length, 2);
assert.notEqual(grouped[0].cities[0].key, grouped[0].cities[1].key);
});
test("sorting reverses quantities within each city and preserves the source array", () => {
const rows = [make(), make({ quantity: 5, model: "另一型号" })];
assert.deepEqual(
groupInventory(rows, "region")[0].cities[0].rows.map((row) => row.quantity),
[5, 2],
);
assert.deepEqual(
groupInventory(rows, "region", true)[0].cities[0].rows.map(
(row) => row.quantity,
),
[2, 5],
);
assert.equal(rows[0].quantity, 2);
assert.deepEqual(groupInventory([], "region"), []);
});
test("drill-down keeps brand, model, city, type, category and company; does not mistake model for contract batch", () => {
const request = buildVehicleModalRequest(
inventorySelection(make(), "Pending"),
"公司甲",
);
assert.equal(request.kind, "vehicles");
if (request.kind !== "vehicles") return;
assert.deepEqual(request.params, {
brand: "明威",
model: "挂车",
location: "嘉兴市",
source: "asset",
category: "Pending",
vehicleType: "挂车",
subject: "公司甲",
});
});
test("matrix merges trailers into other while preserving all five column totals", () => {
const rows = [
make(),
make({ type: "35T", quantity: 3 }),
make({ type: "4.5T普货", quantity: 4 }),
make({ type: "4.5T冷链", quantity: 5 }),
make({ type: "18T", quantity: 6 }),
make({ type: "49T", quantity: 7 }),
];
assert.deepEqual(
INVENTORY_TYPES.map(
(type) =>
inventoryTotals(
rows.filter((row) => inventoryColumn(row.type) === type),
).quantity,
),
[4, 5, 6, 7, 5],
);
assert.equal(inventoryTotals(rows).quantity, 27);
});
test("composition uses independent operation status; abnormal and pending remain subsets", () => {
const total = inventoryTotals([
make({
quantity: 5,
available: 3,
awaiting: 2,
abnormal: 2,
pending: 1,
abnormalTypes: { 维修: 2 },
}),
make({
quantity: 2,
available: 0,
awaiting: 2,
abnormal: 1,
pending: 0,
abnormalTypes: { 保养: 1 },
}),
]);
assert.equal(total.available + total.awaiting, total.quantity);
assert.equal(total.quantity, 7);
assert.equal(total.abnormal, 3);
assert.deepEqual(total.abnormalTypes, { 维修: 2, 保养: 1 });
assert.equal(inventoryOperation("在库"), "unclassified");
});
test("model tags separate operator and color while retaining a model-first name", () => {
assert.deepEqual(inventoryModel("现代4.5T普货(恒运)", "现代"), {
name: "4.5T普货",
operator: "恒运",
});
assert.deepEqual(inventoryModel("现代18T双飞翼(羚牛)", "帕力安牌"), {
name: "18T双飞翼",
operator: "羚牛",
});
assert.deepEqual(inventoryModel("49T宇通", "宇通"), {
name: "49T",
operator: "",
});
assert.deepEqual(inventoryModel("飞驰18T(红车)", "飞驰"), {
name: "18T",
operator: "",
});
assert.deepEqual(inventoryModel("49T飞驰(浙氢-红)", "飞驰"), {
name: "49T",
operator: "浙氢",
});
});
test("drill-down intersects exact geographic/model/brand keys with color and operational status", () => {
const row = make();
const selection = scopedInventorySelection(
{ rows: [row], label: "华东 / 嘉兴市 / 其他" },
{ inventoryColor: "白", inventoryOperation: "available" },
);
const vehicle = {
inventoryKey: inventoryKey(row),
color: "白",
operationStatus: "3",
} as VehicleListItem;
assert.equal(matchesInventorySelection(vehicle, selection), true);
assert.equal(
matchesInventorySelection({ ...vehicle, color: "红" }, selection),
false,
);
assert.equal(
matchesInventorySelection({ ...vehicle, operationStatus: "4" }, selection),
false,
);
assert.equal(
matchesInventorySelection(
{ ...vehicle, inventoryKey: inventoryKey({ ...row, region: "华南" }) },
selection,
),
false,
);
assert.equal(
matchesInventorySelection(vehicle, { ...selection, inventoryKeys: [] }),
false,
);
});
test("known color aliases combine without merging distinct multicolor values", () => {
assert.equal(inventoryColor("红"), inventoryColor("红色"));
assert.equal(inventoryColor("白车"), "白色");
assert.equal(inventoryColor("蓝白绿"), "蓝白绿");
assert.equal(inventoryColor(null), "未标注颜色");
});
@@ -0,0 +1,164 @@
import {
emptyInventoryCounts,
inventoryKey,
inventoryOperation,
inventoryColor,
} from "../../../shared/assets/inventory";
import type { VehicleListItem } from "../types";
import type { RegionalInventoryStats } from "../types";
import type { VehicleModalCategory, VehicleModalSelection } from "../model";
export type InventoryGrouping = "region" | "type";
export function groupInventory(
rows: RegionalInventoryStats[],
grouping: InventoryGrouping,
ascending = false,
) {
const groups = new Map<string, RegionalInventoryStats[]>();
for (const row of rows) {
const key = row[grouping] || "未归属";
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(row);
}
return [...groups]
.map(([name, items]) => {
const cities = new Map<string, RegionalInventoryStats[]>();
for (const row of items) {
// A city may be missing or share a name with a city in another region.
const key = JSON.stringify([row.region, row.city]);
if (!cities.has(key)) cities.set(key, []);
cities.get(key)!.push(row);
}
return {
name,
total: items.reduce((sum, row) => sum + row.quantity, 0),
cities: [...cities]
.map(([key, records]) => ({
key,
name: records[0].city || "未归属城市",
region: records[0].region || "未归属",
total: records.reduce((sum, row) => sum + row.quantity, 0),
rows: [...records].sort(
(a, b) =>
(ascending ? 1 : -1) * (a.quantity - b.quantity) ||
a.model.localeCompare(b.model, "zh-CN"),
),
}))
.sort(
(a, b) =>
b.total - a.total || a.name.localeCompare(b.name, "zh-CN"),
),
};
})
.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name, "zh-CN"));
}
export function inventorySelection(
row: RegionalInventoryStats,
category: VehicleModalCategory = "Inventory",
): VehicleModalSelection {
return {
batch: "All",
source: "asset",
category,
brand: row.brand,
model: row.model,
location: row.city,
type: row.type,
};
}
export const INVENTORY_TYPES = [
"4.5T普货",
"4.5T冷链",
"18T",
"49T",
"其他",
] as const;
export function inventoryColumn(type: string) {
return INVENTORY_TYPES.includes(type as (typeof INVENTORY_TYPES)[number])
? type
: "其他";
}
export function inventoryTotals(rows: RegionalInventoryStats[]) {
const result = emptyInventoryCounts();
for (const row of rows) {
for (const key of [
"quantity",
"available",
"awaiting",
"abnormal",
"pending",
] as const)
result[key] += row[key] || 0;
result.unclassified +=
row.unclassified ??
Math.max(0, row.quantity - (row.available || 0) - (row.awaiting || 0));
for (const [type, count] of Object.entries(row.abnormalTypes || {}))
result.abnormalTypes[type] = (result.abnormalTypes[type] || 0) + count;
}
return result;
}
/** Display labels never replace the original model used for exact drill-down. */
export function inventoryModel(model: string, brand: string) {
const parentheses = [...model.matchAll(/[((]([^))]+)[))]/g)].map(
(match) => match[1],
);
const colorOnly = /^(?:红|白|蓝|绿|灰|黑|黄|银|蓝白绿)(?:色|车)?$/;
const operator = parentheses
.filter((value) => !colorOnly.test(value))
.map((value) => value.replace(/[--](?:红|白|蓝白绿)(?:色|车)?$/, ""))
.join(" · ");
let name = model.replace(/[((][^))]*[))]/g, "").trim();
for (const label of [
brand,
"现代",
"帕力安牌",
"宇通",
"飞驰",
"苏龙",
"跃进",
"楚风",
]) {
if (label && name.startsWith(label))
name = name.slice(label.length).replace(/^-/, "");
if (label && name.endsWith(label)) name = name.slice(0, -label.length);
}
return { name: name || model, operator };
}
export interface InventoryScope {
rows: RegionalInventoryStats[];
label: string;
initialCategory?: "Abnormal" | "Pending";
}
export function scopedInventorySelection(
scope: InventoryScope,
extra: Partial<VehicleModalSelection> = {},
): VehicleModalSelection {
return {
batch: "All",
model: "All",
location: "All",
source: "asset",
category: "Inventory",
inventoryKeys: scope.rows.map(inventoryKey),
scopeLabel: scope.label,
...extra,
};
}
export function matchesInventorySelection(
vehicle: VehicleListItem,
selection: VehicleModalSelection,
) {
return (
(!selection.inventoryKeys ||
selection.inventoryKeys.includes(vehicle.inventoryKey || "")) &&
(!selection.inventoryOperation ||
inventoryOperation(vehicle.operationStatus) ===
selection.inventoryOperation) &&
(!selection.inventoryColor ||
inventoryColor(vehicle.color) ===
inventoryColor(selection.inventoryColor)) &&
(!selection.abnormalType ||
(vehicle.abnormalMove?.type || "未提供") === selection.abnormalType)
);
}
+253
View File
@@ -0,0 +1,253 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { ArrowUpRight, SlidersHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Field, FieldLabel } from "@/components/ui/field";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useDemoMode } from "../../../components/Blur";
import type { VehicleModalSelection } from "../model";
export type OpenVehicles = (selection: VehicleModalSelection) => void;
export const baseSelection: VehicleModalSelection = {
batch: "All",
model: "All",
location: "All",
source: "asset",
};
export const unique = (values: (string | null | undefined)[]) =>
[...new Set(values.filter((v): v is string => !!v))].sort();
/** 区块级 Tab 统一样式:模块级与卡片内嵌 Tab 共用,避免多套视觉。 */
export const sectionTabsListClass =
"w-full min-h-11 bg-[var(--muted)] md:w-fit";
export const sectionTabsTriggerClass =
"min-h-11 px-4 data-[state=active]:text-[var(--blue)] dark:data-[state=active]:text-[var(--cyan)]";
/** 数字滚动动画:目标值变化时从当前值缓动到新值;尊重 reduced-motion。 */
export function useCountUp(target: number, duration = 800) {
const [value, setValue] = useState(0);
const valueRef = useRef(0);
const rafRef = useRef(0);
useEffect(() => {
const from = valueRef.current;
if (from === target) return undefined;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
valueRef.current = target;
setValue(target);
return undefined;
}
const start = performance.now();
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
const current = Math.round(from + (target - from) * eased);
valueRef.current = current;
setValue(current);
if (t < 1) rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, [target, duration]);
return value;
}
/** 占比条:用于 KPI 卡片内的构成占比(如 租赁/自营)。 */
export function ProportionBar({
segments,
label,
}: {
segments: { value: number; className: string; title: string }[];
label: string;
}) {
const total = segments.reduce((n, s) => n + s.value, 0);
return (
<div
className="flex h-1.5 w-full overflow-hidden rounded-full bg-[var(--fill-3)]"
role="img"
aria-label={label}
>
{total > 0 &&
segments
.filter((s) => s.value > 0)
.map((s) => (
<div
key={s.title}
title={`${s.title} ${s.value.toLocaleString()}`}
className={s.className}
style={{ width: `${(s.value / total) * 100}%` }}
/>
))}
</div>
);
}
export function PrivateText({ children }: { children: ReactNode }) {
return <>{useDemoMode() ? "••••••" : children || "—"}</>;
}
export function Count({
value,
onClick,
label,
}: {
value: number;
onClick: () => void;
label?: string;
}) {
if (!value)
return (
<span className="asset-zero" title="暂无明细">
0
</span>
);
return (
<Button
variant="link"
size="sm"
className="asset-count"
onClick={onClick}
aria-label={
label ? `查看${label}:${value} 条明细` : `查看 ${value} 条明细`
}
>
{value.toLocaleString()}
<ArrowUpRight aria-hidden="true" />
</Button>
);
}
export function Picker({
label,
value = "",
values,
change,
}: {
label: string;
value?: string;
values: string[];
change: (value: string) => void;
}) {
return (
<Field>
<FieldLabel>{label}</FieldLabel>
<Select
value={value || "__all__"}
onValueChange={(v) => change(v === "__all__" ? "" : v)}
>
<SelectTrigger className="min-h-11 w-full" aria-label={label}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="__all__">
全部{label === "所属公司" ? "公司" : label}
</SelectItem>
{values.map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
);
}
export function FilterFields({
fields,
onReset,
}: {
fields: Parameters<typeof Picker>[0][];
onReset: () => void;
}) {
const active = fields.filter((field) => field.value);
return (
<Accordion
type="single"
collapsible
className="rounded-lg border border-[var(--border)] bg-[var(--bg-secondary)] px-3"
>
<AccordionItem value="filters" className="border-0">
<AccordionTrigger className="min-h-11 py-3 hover:no-underline">
<span className="flex min-w-0 flex-wrap items-center gap-2 text-left">
<SlidersHorizontal className="size-4 shrink-0 text-blue-600 dark:text-blue-300" />
<span className="font-semibold">筛选条件</span>
{active.length ? (
active.map((field) => (
<Badge
key={field.label}
variant="secondary"
className="max-w-52 truncate bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-200"
>
{field.label}:{field.value}
</Badge>
))
) : (
<span className="text-sm font-normal text-muted-foreground">
未设条件
</span>
)}
</span>
</AccordionTrigger>
<AccordionContent className="pb-3">
<div className="grid grid-cols-2 items-end gap-3 md:grid-cols-3 xl:grid-cols-6">
{fields.map((field) => (
<Picker key={field.label} {...field} />
))}
<Button
className="min-h-11"
variant="outline"
onClick={onReset}
disabled={!active.length}
>
重置筛选
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
export function Failure({
message,
retry,
}: {
message: string;
retry?: () => void;
}) {
return (
<Alert variant="destructive">
<AlertTitle>数据加载失败</AlertTitle>
<AlertDescription>
{message}
{retry && (
<Button variant="outline" onClick={retry}>
重试
</Button>
)}
</AlertDescription>
</Alert>
);
}
export function useMobile() {
const [mobile, setMobile] = useState(
() => window.matchMedia("(max-width: 767px)").matches,
);
useEffect(() => {
const query = window.matchMedia("(max-width: 767px)");
const update = () => setMobile(query.matches);
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return mobile;
}
@@ -0,0 +1,62 @@
import { useEffect, useState } from "react";
const TAB_IDS = ["overview", "department", "region", "customer"] as const;
const SECTION_IDS = ["inventory", "models", "flow"] as const;
export type AssetTab = (typeof TAB_IDS)[number];
export type AssetSection = (typeof SECTION_IDS)[number];
interface AssetHashState {
tab: AssetTab;
section: AssetSection;
}
const DEFAULT_STATE: AssetHashState = { tab: "overview", section: "inventory" };
const isTab = (value: string | undefined): value is AssetTab =>
Boolean(value) && (TAB_IDS as readonly string[]).includes(value as string);
const isSection = (value: string | undefined): value is AssetSection =>
Boolean(value) && (SECTION_IDS as readonly string[]).includes(value as string);
function parseHash(): AssetHashState {
const [first, second, third] = window.location.hash.slice(1).split("/");
if (first !== "assets") return DEFAULT_STATE;
return {
tab: isTab(second) ? second : "overview",
section: isSection(third) ? third : "inventory",
};
}
function writeHash(tab: AssetTab, section: AssetSection) {
const parts = ["assets", tab];
// 默认子页签不写入,保持 `#assets/<tab>` 简洁。
if (tab === "overview" && section !== "inventory") parts.push(section);
const hash = `#${parts.join("/")}`;
window.history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}${hash}`,
);
}
/**
* 资产模块页签状态 ↔ URL hash 二级/三级段(`#assets/<tab>[/<section>]`)。
* 默认值不写入 hash,刷新可恢复,链接可分享;与 Shell 的一级模块段兼容。
*/
export function useAssetHash() {
const [state, setState] = useState<AssetHashState>(parseHash);
useEffect(() => {
const onChange = () => setState(parseHash());
window.addEventListener("hashchange", onChange);
return () => window.removeEventListener("hashchange", onChange);
}, []);
const update = (next: Partial<AssetHashState>) => {
const merged = { ...state, ...next };
writeHash(merged.tab, merged.section);
setState(merged);
};
return [state, update] as const;
}
+8
View File
@@ -58,6 +58,12 @@ export type VehicleModalCategory =
| 'Operating';
export interface VehicleModalSelection {
inventoryKeys?: string[];
inventoryOperation?: "available" | "awaiting" | "unclassified";
inventoryColor?: string;
abnormalType?: string;
scopeLabel?: string;
brand?: string;
batch: string;
model: string;
location: string;
@@ -75,6 +81,7 @@ export interface VehicleModalSelection {
}
export interface VehicleListRequestParams {
brand?: string;
batch?: string;
model?: string;
location?: string;
@@ -174,6 +181,7 @@ export function buildVehicleModalRequest(
}
const params: VehicleListRequestParams = {};
if (selection.brand) params.brand = selection.brand;
if (selection.vehicleType) params.vehicleType = selection.vehicleType;
if (selection.batch !== 'All') params.batch = selection.batch;
if (selection.model !== 'All') params.model = selection.model;
+9
View File
@@ -1,3 +1,4 @@
import type { InventoryCounts } from '../../shared/assets/inventory';
import type { AbnormalMoveDetail } from '../../shared/assets/abnormal-move';
export interface SummaryData {
@@ -103,6 +104,9 @@ export interface InventoryTypeSummary {
}
export interface VehicleListItem {
color?: string;
operationStatus?: string | null;
inventoryKey?: string;
pendingDelivery?: { contractNo: string | null; customerName: string | null } | null;
abnormalMove?: AbnormalMoveDetail | null;
id: number;
@@ -126,6 +130,11 @@ export interface VehicleListItem {
}
export interface RegionalInventoryStats {
available?: number;
awaiting?: number;
unclassified?: number;
abnormalTypes?: Record<string, number>;
variants?: Record<string, InventoryCounts>;
abnormal?: number;
pending?: number;
region: string;