feat: polish BI dashboards and bump version
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
lingniu
2026-06-27 21:59:33 +08:00
parent 5377d2c225
commit b0caa5afcb
33 changed files with 2363 additions and 483 deletions

View File

@@ -14,8 +14,12 @@ import {
Filter,
ArrowRightLeft,
MapPin,
Download,
CalendarDays,
X,
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import * as XLSX from 'xlsx';
import {
BarChart,
Bar,
@@ -30,12 +34,13 @@ import {
LabelList,
} from 'recharts';
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, type SubjectOption } from './api';
import type { WeeklyDetailItem } from './api';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, fetchFlowStats, type SubjectOption } from './api';
import type { FlowDetailItem, FlowStatsResponse, FlowType, WeeklyDetailItem } from './api';
import { SearchSelect } from '../../components/SearchSelect';
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
import Blur from '../../components/Blur';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
// --- Constants ---
@@ -92,6 +97,33 @@ function formatLocalDateTime(date: Date): string {
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
}
function formatLocalDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function addDays(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function getWeeklyFlowRange() {
const now = new Date();
const day = now.getDay();
const end = day === 6 ? addDays(now, -1) : day === 0 ? addDays(now, -2) : addDays(now, 5 - day);
const start = addDays(end, -6);
return { start: formatLocalDate(start), end: formatLocalDate(end) };
}
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' },
};
export default function AssetsModule() {
const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview');
const [tabReady, setTabReady] = useState(true);
@@ -140,6 +172,11 @@ export default function AssetsModule() {
const [error, setError] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<string>(() => formatLocalDateTime(new Date()));
const [modalLoading, setModalLoading] = useState(false);
const [flowRange, setFlowRange] = useState(() => getWeeklyFlowRange());
const [flowStats, setFlowStats] = useState<FlowStatsResponse | null>(null);
const [flowLoading, setFlowLoading] = useState(false);
const [flowDailyExpanded, setFlowDailyExpanded] = useState(false);
const [selectedFlow, setSelectedFlow] = useState<{ date: string; type: FlowType } | null>(null);
// Dept/Region/Customer data
const [deptData, setDeptData] = useState<DeptGroup[]>([]);
@@ -222,6 +259,24 @@ export default function AssetsModule() {
return () => clearInterval(interval);
}, [loadData]);
useEffect(() => {
let cancelled = false;
setFlowLoading(true);
fetchFlowStats({ start: flowRange.start, end: flowRange.end, subject: selectedSubject })
.then((data) => {
if (!cancelled) setFlowStats(data);
})
.catch(() => {
if (!cancelled) setFlowStats(null);
})
.finally(() => {
if (!cancelled) setFlowLoading(false);
});
return () => {
cancelled = true;
};
}, [flowRange.start, flowRange.end, selectedSubject]);
// 归属公司列表(仅首次加载,公司集合相对稳定)
useEffect(() => {
fetchSubjects().then(setSubjects).catch(() => setSubjects([]));
@@ -521,6 +576,40 @@ export default function AssetsModule() {
return mp;
}), [modalWeeklyDetail, modalFilters.plateNumber]);
const selectedFlowDetails = useMemo(() => {
if (!flowStats || !selectedFlow) return [];
return flowStats.details.filter((item) => item.date === selectedFlow.date && item.type === selectedFlow.type);
}, [flowStats, selectedFlow]);
const exportFlowDetails = useCallback((rows?: FlowDetailItem[], title = '资产流转明细') => {
const source = rows ?? flowStats?.details ?? [];
if (source.length === 0) return;
const table = source.map((item) => ({
日期: item.date,
类型: item.typeLabel,
车牌: item.plateNumber,
流转时间: item.eventTime || '',
提交时间: item.submitTime || '',
部门: item.department || '',
业务负责人: item.manager || '',
客户: item.customerName || '',
}));
const ws = XLSX.utils.json_to_sheet(table);
ws['!cols'] = [
{ wch: 14 },
{ wch: 8 },
{ wch: 14 },
{ wch: 20 },
{ wch: 20 },
{ wch: 16 },
{ wch: 14 },
{ wch: 24 },
];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '明细');
XLSX.writeFile(wb, `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
}, [flowRange.end, flowRange.start, flowStats]);
const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]);
useEffect(() => {
if (customerChartView === 'province') {
@@ -541,35 +630,57 @@ export default function AssetsModule() {
if (loading && !summary) {
return (
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="animate-spin text-blue-500" size={32} />
<span className="text-sm text-gray-500">...</span>
</div>
</div>
<PageFrame
title="车辆资产中心"
subtitle="正在同步车辆、部门、区域与客户归属数据,加载完成后可继续穿透查看明细。"
icon={Truck}
eyebrow="ASSET INTELLIGENCE"
meta="数据准备中"
>
<SurfaceCard className="min-h-[360px]">
<div className="grid gap-4 md:grid-cols-4">
{[0, 1, 2, 3].map(item => (
<SkeletonBlock key={item} className="h-28" />
))}
</div>
<div className="mt-8">
<LoadingState label="正在加载车辆资产数据" />
</div>
</SurfaceCard>
</PageFrame>
);
}
if (error && !summary) {
return (
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
<div className="flex flex-col items-center gap-3 text-center">
<div className="text-red-500 text-lg font-bold"></div>
<div className="text-sm text-gray-500">{error}</div>
<button onClick={loadData} className="mt-2 px-4 py-2 bg-blue-500 text-white rounded text-sm hover:bg-blue-600">
</button>
</div>
</div>
<PageFrame
title="车辆资产中心"
subtitle="车辆资产数据暂时没有返回,请重试或稍后再看。"
icon={Truck}
eyebrow="ASSET INTELLIGENCE"
meta="加载失败"
>
<SurfaceCard className="min-h-[360px]">
<div className="mt-6 flex flex-col items-center gap-4">
<ErrorState message={error} />
<button onClick={loadData} className="rounded-xl bg-slate-900 px-4 py-2 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800">
</button>
</div>
</SurfaceCard>
</PageFrame>
);
}
const SUMMARY = summary!;
const operatingRate = SUMMARY.totalAssets > 0 ? SUMMARY.operating.total / SUMMARY.totalAssets * 100 : 0;
const inventoryRate = SUMMARY.totalAssets > 0 ? SUMMARY.inventory.total / SUMMARY.totalAssets * 100 : 0;
const pendingRate = SUMMARY.totalAssets > 0 ? SUMMARY.pendingDelivery / SUMMARY.totalAssets * 100 : 0;
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6 relative">
<div className="min-h-screen bg-[var(--app-bg)] text-gray-800 font-sans p-3 md:p-6 relative">
{/* Compact Header Bar */}
<div className="sticky top-0 z-40 -mx-6 -mt-6 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80">
<div className="sticky top-0 z-40 -mx-3 -mt-3 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80 md:-mx-6 md:-mt-6">
{/* Title row */}
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">-BI</h1>
@@ -753,11 +864,11 @@ export default function AssetsModule() {
{tabReady && activeTab === 'overview' && (
<>
{/* Header Summary - Ultra Compact */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-2 mb-2">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">
{/* Total Assets */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-gray-50 transition-colors"
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
<div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-400">
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
<Truck size={14} />
</div>
<div>
@@ -767,9 +878,9 @@ export default function AssetsModule() {
</div>
{/* Operating */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-blue-50 transition-colors"
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset', title: '正在运营' })}>
<div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500">
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
<Activity size={14} />
</div>
<div>
@@ -782,9 +893,9 @@ export default function AssetsModule() {
</div>
{/* Inventory */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-gray-50 transition-colors"
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
<div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-500">
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
<Warehouse size={14} />
</div>
<div>
@@ -797,9 +908,9 @@ export default function AssetsModule() {
</div>
{/* Pending */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-blue-50 transition-colors"
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}>
<div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500">
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
<PlusCircle size={14} />
</div>
<div>
@@ -808,41 +919,161 @@ export default function AssetsModule() {
</div>
</div>
{/* Dynamics */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm col-span-2">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[9px] text-gray-400 font-bold uppercase tracking-tight"></div>
<div className="text-[7px] text-gray-300 font-normal italic">-</div>
</div>
<div className="flex justify-between items-center gap-1">
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-green-50 py-1 rounded transition-all group">
<span className="text-xs font-bold text-gray-800 group-hover:text-green-600">{SUMMARY.weeklyNew}</span>
<span className="text-[8px] text-green-500/80 font-bold mt-0.5"></span>
</div>
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="shrink-0 text-[11px] font-black text-slate-400"></div>
<div className="grid min-w-0 flex-1 grid-cols-3 divide-x divide-slate-100 text-center">
<div className="px-2">
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
</div>
<div className="w-[1px] h-3 bg-gray-100"></div>
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-blue-50 py-1 rounded transition-all group"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Delivered', source: 'asset', title: '本周交车' })}>
<span className="text-xs font-bold text-gray-800 group-hover:text-blue-600">{SUMMARY.weeklyDelivered}</span>
<span className="text-[8px] text-blue-500/80 font-bold mt-0.5"></span>
<div className="px-2">
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
</div>
<div className="w-[1px] h-3 bg-gray-100"></div>
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-orange-50 py-1 rounded transition-all group"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Returned', source: 'asset', title: '本周还车' })}>
<span className="text-xs font-bold text-gray-800 group-hover:text-orange-600">{SUMMARY.weeklyReturned}</span>
<span className="text-[8px] text-orange-500/80 font-bold mt-0.5"></span>
</div>
<div className="w-[1px] h-3 bg-gray-100"></div>
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-purple-50 py-1 rounded transition-all group"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Replaced', source: 'asset', title: '本周替换' })}>
<span className="text-xs font-bold text-gray-800 group-hover:text-purple-600">{SUMMARY.weeklyReplaced}</span>
<span className="text-[8px] text-purple-500/80 font-bold mt-0.5"></span>
<div className="px-2">
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 text-base font-black text-amber-600">{pendingRate.toFixed(1)}%</div>
</div>
</div>
</div>
</div>
<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 className="mt-0.5 truncate text-[10px] font-bold text-slate-400">- · </div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => exportFlowDetails()}
disabled={!flowStats?.details.length}
className="inline-flex h-8 items-center justify-center gap-1 rounded-xl border border-slate-200 bg-slate-50 px-2.5 text-[11px] font-black text-slate-600 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
>
<Download size={13} />
</button>
</div>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="relative cursor-pointer rounded-xl border border-slate-100 bg-slate-50 px-2.5 py-1.5 transition hover:border-blue-100 hover:bg-blue-50/50">
<span className="block text-[9px] font-black text-slate-400"></span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className="text-[12px] font-black text-slate-700">{flowRange.start.replaceAll('-', '/')}</span>
<CalendarDays size={13} className="text-slate-400" />
</span>
<input
type="date"
value={flowRange.start}
onChange={(e) => setFlowRange((prev) => ({ ...prev, start: e.target.value }))}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
<label className="relative cursor-pointer rounded-xl border border-slate-100 bg-slate-50 px-2.5 py-1.5 transition hover:border-blue-100 hover:bg-blue-50/50">
<span className="block text-[9px] font-black text-slate-400"></span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className="text-[12px] font-black text-slate-700">{flowRange.end.replaceAll('-', '/')}</span>
<CalendarDays size={13} className="text-slate-400" />
</span>
<input
type="date"
value={flowRange.end}
onChange={(e) => setFlowRange((prev) => ({ ...prev, end: e.target.value }))}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
</div>
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5">
<div className="grid grid-cols-4 items-center text-center">
<div className="px-2">
<div className="text-lg font-black leading-none text-slate-950">{flowStats?.totals.total ?? 0}</div>
<div className="mt-1 text-[10px] font-black text-slate-400"></div>
</div>
{(['delivered', 'returned', 'replaced'] as FlowType[]).map((type) => (
<div key={type} className="border-l border-slate-200/70 px-2">
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>
{flowStats?.totals[type] ?? 0}
</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
</div>
))}
</div>
</div>
<button
type="button"
data-testid="asset-flow-daily-toggle"
onClick={() => setFlowDailyExpanded((prev) => !prev)}
className="mt-2 flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[12px] font-black text-slate-500 transition hover:border-blue-100 hover:bg-blue-50/50 hover:text-blue-600"
>
<span>{flowDailyExpanded ? '收起每日明细' : '展开每日明细'}</span>
<span className="flex items-center gap-2 text-[11px] text-slate-400">
{flowStats?.daily.length ?? 0}
<ChevronDown size={15} className={`transition-transform ${flowDailyExpanded ? 'rotate-180' : ''}`} />
</span>
</button>
<AnimatePresence initial={false}>
{flowDailyExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="mt-3 max-h-[280px] space-y-2 overflow-y-auto pr-1">
{flowLoading && (
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">...</div>
)}
{!flowLoading && flowStats?.daily.map((day) => (
<div key={day.date} className="rounded-2xl border border-slate-100 bg-white px-3 py-3 shadow-sm">
<div className="flex items-center justify-between">
<div className="text-[11px] font-black text-slate-400">{day.date}</div>
<div className="text-[10px] font-bold italic text-slate-300"></div>
</div>
<div className="mt-2 grid grid-cols-4 items-center text-center">
<div className="px-2">
<div className="text-lg font-black leading-none text-slate-900">{day.total}</div>
<div className="mt-1 text-[10px] font-black text-slate-400"></div>
</div>
{(['delivered', 'returned', 'replaced'] as FlowType[]).map((type) => {
const count = day[type];
return (
<button
key={type}
type="button"
data-testid={`asset-flow-cell-${day.date}-${type}`}
disabled={count === 0}
onClick={() => setSelectedFlow({ date: day.date, type })}
className="border-l border-slate-100 px-2 text-center transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-35"
>
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>{count}</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
</button>
);
})}
</div>
</div>
))}
{!flowLoading && !flowStats?.daily.length && (
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400"></div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Asset Summary Table */}
<div className="bg-white rounded-sm border border-gray-100 shadow-sm overflow-hidden mb-6">
<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>
@@ -2647,6 +2878,123 @@ export default function AssetsModule() {
)}
{/* Flow Detail Modal */}
<AnimatePresence>
{selectedFlow && (
<div className="fixed inset-0 z-[1000] flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-4">
<motion.div
data-testid="asset-flow-detail-modal"
initial={{ opacity: 0, y: 28, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
className="flex max-h-[88vh] w-full flex-col overflow-hidden rounded-t-3xl bg-white shadow-2xl sm:max-w-5xl sm:rounded-3xl"
>
<div className="border-b border-slate-100 bg-slate-950 px-4 py-4 text-white sm:px-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400"></div>
<h3 className="mt-1 text-lg font-black">
{selectedFlow.date} · {FLOW_META[selectedFlow.type].label}
</h3>
<div className="mt-1 text-[12px] font-bold text-slate-300">
{selectedFlowDetails.length}
</div>
</div>
<button
type="button"
onClick={() => setSelectedFlow(null)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
>
<X size={18} />
</button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => exportFlowDetails(selectedFlowDetails, `${selectedFlow.date}-${FLOW_META[selectedFlow.type].label}明细`)}
disabled={selectedFlowDetails.length === 0}
className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
>
<Download size={13} />
</button>
</div>
</div>
<div className="overflow-auto bg-slate-50 p-3 sm:p-4">
<div className="hidden overflow-hidden rounded-2xl border border-slate-100 bg-white lg:block">
<table className="w-full table-fixed text-left">
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
<tr>
<th className="w-28 px-3 py-3"></th>
<th className="w-40 px-3 py-3">{FLOW_META[selectedFlow.type].label}</th>
<th className="w-40 px-3 py-3"></th>
<th className="w-36 px-3 py-3"></th>
<th className="w-28 px-3 py-3"></th>
<th className="px-3 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-[12px] font-bold text-slate-700">
{selectedFlowDetails.map((item) => (
<tr key={item.id} className="hover:bg-blue-50/40">
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}</td>
<td className="px-3 py-3 text-slate-500">{item.eventTime || '-'}</td>
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
<td className="px-3 py-3">{item.department || '-'}</td>
<td className="px-3 py-3">{item.manager || '-'}</td>
<td className="px-3 py-3">{item.customerName || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="space-y-2 lg:hidden">
{selectedFlowDetails.map((item) => (
<div key={item.id} className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-base font-black text-slate-950">{item.plateNumber}</div>
<div className={`mt-1 inline-flex rounded-full border px-2 py-0.5 text-[10px] font-black ${FLOW_META[item.type].chip}`}>
{item.typeLabel}
</div>
</div>
<div className="text-right text-[10px] font-bold text-slate-400">
<div></div>
<div className="mt-0.5 text-[11px] text-blue-600">{item.submitTime?.slice(5, 16) || '-'}</div>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">{item.typeLabel}</div>
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 font-bold text-slate-700">{item.manager || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 font-bold text-slate-700">{item.department || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 line-clamp-2 font-bold text-slate-700">{item.customerName || '-'}</div>
</div>
</div>
</div>
))}
</div>
{selectedFlowDetails.length === 0 && (
<div className="rounded-2xl bg-white px-4 py-10 text-center text-sm font-bold text-slate-400"></div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Vehicle Detail Modal */}
<AnimatePresence>
{showPlateNumbers && (