feat: 羚牛 BI 报表服务初始版本
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful

- Hono + TypeScript 后端,连接 MySQL 数据库
- React + Vite + Tailwind 前端
- 车辆资产实时汇总(按车型/品牌型号分组)
- 本周交车/还车/替换统计(关联业务单据)
- 车牌号详情弹窗
- Dockerfile + Woodpecker CI 流水线

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-03-26 14:02:49 +08:00
commit 0cc5024132
23 changed files with 5783 additions and 0 deletions

772
src/App.tsx Normal file
View File

@@ -0,0 +1,772 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Truck,
Warehouse,
Activity,
PlusCircle,
MinusCircle,
History,
ChevronDown,
ChevronRight,
Info,
Loader2,
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import type { SummaryData, TypeSummary, VehicleListItem } from './types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail } from './api';
import type { WeeklyDetailItem } from './api';
export default function App() {
const [theme, setTheme] = useState<'soft' | 'minimal' | 'vibrant'>('soft');
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
const [expandedAssetTypes, setExpandedAssetTypes] = useState<Set<string>>(new Set());
const [showPlateNumbers, setShowPlateNumbers] = useState<{
batch: string;
model: string;
location: string;
category?: 'Inventory' | 'Pending' | 'Delivered' | 'Returned' | 'Replaced';
} | null>(null);
// Data state
const [summary, setSummary] = useState<SummaryData | null>(null);
const [processedData, setProcessedData] = useState<TypeSummary[]>([]);
const [modalVehicles, setModalVehicles] = useState<VehicleListItem[]>([]);
const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<string>('');
const [modalLoading, setModalLoading] = useState(false);
const loadData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [s, byType] = await Promise.all([
fetchSummary(),
fetchByType(),
]);
setSummary(s);
setProcessedData(byType);
setLastUpdate(new Date().toLocaleString('zh-CN'));
} catch (e) {
setError(e instanceof Error ? e.message : '数据加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
const interval = setInterval(loadData, 60 * 1000);
return () => clearInterval(interval);
}, [loadData]);
// Load modal vehicles
useEffect(() => {
if (!showPlateNumbers) {
setModalVehicles([]);
setModalWeeklyDetail([]);
return;
}
setModalLoading(true);
const cat = showPlateNumbers.category;
// Weekly categories use the dedicated weekly-detail endpoint
const weeklyTypes: Record<string, string> = { Delivered: 'delivered', Returned: 'returned', Replaced: 'replaced', Pending: 'pending' };
if (cat && weeklyTypes[cat]) {
setModalVehicles([]);
fetchWeeklyDetail(weeklyTypes[cat])
.then(setModalWeeklyDetail)
.catch(() => setModalWeeklyDetail([]))
.finally(() => setModalLoading(false));
return;
}
// Normal vehicle list
setModalWeeklyDetail([]);
const params: Record<string, string> = {};
if (showPlateNumbers.batch !== 'All') params.batch = showPlateNumbers.batch;
if (showPlateNumbers.model !== 'All') params.model = showPlateNumbers.model;
if (showPlateNumbers.location !== 'All') params.location = showPlateNumbers.location;
if (cat === 'Inventory') params.status = 'Inventory';
fetchVehicleList(params)
.then(setModalVehicles)
.catch(() => setModalVehicles([]))
.finally(() => setModalLoading(false));
}, [showPlateNumbers]);
const allTypesExpanded = processedData.length > 0 && processedData.every((t) => expandedAssetTypes.has(t.type));
const toggleAllAssetTypes = () => {
if (allTypesExpanded) {
setExpandedAssetTypes(new Set());
} else {
setExpandedAssetTypes(new Set(processedData.map((t) => t.type)));
}
};
const toggleAssetType = (type: string) => {
const newSet = new Set(expandedAssetTypes);
if (newSet.has(type)) newSet.delete(type);
else newSet.add(type);
setExpandedAssetTypes(newSet);
};
const toggleModel = (model: string) => {
const newSet = new Set(expandedModels);
if (newSet.has(model)) newSet.delete(model);
else newSet.add(model);
setExpandedModels(newSet);
};
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>
);
}
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>
);
}
const SUMMARY = summary!;
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6">
{/* Main Title and Global Descriptions */}
<div className="mb-6 text-center relative">
<h1 className="text-2xl font-bold text-gray-900 mb-2"></h1>
<div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-1 text-[11px] text-gray-500">
<div className="flex items-center gap-1.5">
<span className="w-1 h-1 rounded-full bg-blue-500"></span>
: {lastUpdate}
</div>
<div className="flex items-center gap-1.5">
<span className="w-1 h-1 rounded-full bg-green-500"></span>
</div>
{loading && (
<div className="flex items-center gap-1.5">
<Loader2 className="animate-spin" size={10} />
...
</div>
)}
</div>
{/* Theme Switcher */}
<div className="absolute top-0 right-0 hidden sm:flex bg-gray-100 p-0.5 rounded-lg text-[10px]">
<button
onClick={() => setTheme('soft')}
className={`px-2 py-1 rounded-md transition-all ${theme === 'soft' ? 'bg-white text-blue-600 shadow-sm font-bold' : 'text-gray-500 hover:text-gray-700'}`}
>
</button>
<button
onClick={() => setTheme('minimal')}
className={`px-2 py-1 rounded-md transition-all ${theme === 'minimal' ? 'bg-white text-blue-600 shadow-sm font-bold' : 'text-gray-500 hover:text-gray-700'}`}
>
</button>
<button
onClick={() => setTheme('vibrant')}
className={`px-2 py-1 rounded-md transition-all ${theme === 'vibrant' ? 'bg-white text-blue-600 shadow-sm font-bold' : 'text-gray-500 hover:text-gray-700'}`}
>
</button>
</div>
</div>
{/* Header Summary - Ultra Compact */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2 mb-6">
{/* Total Assets */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2">
<div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-400">
<Truck size={14} />
</div>
<div>
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5"></div>
<div className="text-base font-bold text-gray-800 leading-none">{SUMMARY.totalAssets.toLocaleString()}</div>
</div>
</div>
{/* Operating */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2">
<div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500">
<Activity size={14} />
</div>
<div>
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5"></div>
<div className="flex items-baseline gap-1">
<span className="text-base font-bold text-blue-600 leading-none">{SUMMARY.operating.total}</span>
<span className="text-[8px] text-gray-400 leading-none">
{SUMMARY.operating.self} {SUMMARY.operating.leased}
</span>
</div>
</div>
</div>
{/* Inventory */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2">
<div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-500">
<Warehouse size={14} />
</div>
<div>
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5"></div>
<div className="flex items-baseline gap-1">
<span className="text-base font-bold text-gray-800 leading-none">{SUMMARY.inventory.total}</span>
<span className="text-[8px] text-gray-400 leading-none">
{SUMMARY.inventory.inStock} {SUMMARY.inventory.abnormal}
</span>
</div>
</div>
</div>
{/* Pending */}
<div
className="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"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending' })}
>
<div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500">
<PlusCircle size={14} />
</div>
<div>
<div className="text-[9px] text-blue-500 font-bold uppercase leading-none mb-0.5"></div>
<div className="text-base font-bold text-blue-600 leading-none">{SUMMARY.pendingDelivery}</div>
</div>
</div>
{/* New */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2">
<div className="w-7 h-7 rounded bg-green-50 flex items-center justify-center text-green-500">
<PlusCircle size={14} />
</div>
<div>
<div className="text-[9px] text-green-500 font-bold uppercase leading-none mb-0.5"></div>
<div className="text-base font-bold text-green-600 leading-none">{SUMMARY.weeklyNew}</div>
</div>
</div>
{/* Removed */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2">
<div className="w-7 h-7 rounded bg-red-50 flex items-center justify-center text-red-500">
<MinusCircle size={14} />
</div>
<div>
<div className="text-[9px] text-red-500 font-bold uppercase leading-none mb-0.5"></div>
<div className="text-base font-bold text-red-600 leading-none">{SUMMARY.weeklyRemoved}</div>
</div>
</div>
{/* Dynamics */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm col-span-2 md:col-span-1">
<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-blue-50 py-1 rounded transition-all group"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Delivered' })}
>
<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>
<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' })}
>
<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' })}
>
<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>
</div>
</div>
</div>
{/* Main Content Area */}
<div className="flex flex-col gap-6">
{/* Asset Summary Table with Dimension Switch */}
<div className="bg-white rounded-sm border border-gray-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 */}
<div className="hidden lg:block overflow-x-auto">
<table className="w-full text-left border-collapse table-fixed min-w-[1200px]">
<thead>
<tr className="bg-gray-50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-100">
<th className="p-3 font-semibold border-r border-gray-100 w-24">
<button onClick={toggleAllAssetTypes} className="flex items-center gap-1 hover:text-blue-600 transition-colors">
{allTypesExpanded ? <MinusCircle size={12} /> : <PlusCircle size={12} />}
<span></span>
</button>
</th>
<th className="p-3 font-semibold border-r border-gray-100 w-48"></th>
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/30 w-24"></th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24"></th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">-</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">-广</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">-</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">-</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">-</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24"></th>
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-green-50/30 w-24"></th>
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/20 w-24"></th>
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-orange-50/20 w-24"></th>
<th className="p-3 font-semibold text-center bg-purple-50/20 w-24"></th>
</tr>
</thead>
<tbody className="text-xs">
{processedData.map((typeGroup) => (
<React.Fragment key={typeGroup.type}>
{/* Category Header Row */}
<tr
className={`border-b border-gray-100 cursor-pointer transition-all ${
theme === 'vibrant'
? 'bg-blue-600 text-white hover:bg-blue-700'
: theme === 'minimal'
? 'bg-white border-l-4 border-blue-500 hover:bg-gray-50'
: 'bg-blue-50/50 hover:bg-blue-50 transition-colors'
}`}
onClick={() => toggleAssetType(typeGroup.type)}
>
{expandedAssetTypes.has(typeGroup.type) ? (
<td colSpan={14} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
<div className="flex items-center gap-2">
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} />
<span>{typeGroup.type}</span>
</div>
</td>
) : (
<>
<td className={`p-3 font-bold border-r border-gray-100 ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
<div className="flex items-center gap-2">
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-gray-400'} />
<span>{typeGroup.type}</span>
</div>
</td>
<td className={`p-3 border-r border-gray-100 text-[11px] ${theme === 'vibrant' ? 'text-white/70' : 'text-gray-400'} italic`}></td>
<td className={`p-3 text-center border-r border-gray-100 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-gray-700'}`}>{typeGroup.totalAssets}</td>
<td className={`p-3 text-center border-r border-gray-100 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-600'}`}>{typeGroup.totalInventory}</td>
{['嘉兴', '广东', '北京', '新疆', '其他'].map((reg) => (
<td key={reg} className={`p-3 text-center border-r border-gray-100 font-bold ${theme === 'vibrant' ? 'text-white/80' : 'text-blue-600'}`}>
{(typeGroup.inventoryRegions?.[reg] || 0) > 0 ? typeGroup.inventoryRegions[reg] : ''}
</td>
))}
<td className={`p-3 text-center border-r border-gray-100 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-gray-600'}`}>{typeGroup.pending || ''}</td>
<td className={`p-3 text-center border-r border-gray-100 font-bold bg-green-50/10 ${theme === 'vibrant' ? 'text-white' : 'text-green-600'}`}>{typeGroup.totalOperating}</td>
<td className={`p-3 text-center border-r border-gray-100 font-bold bg-blue-50/5 ${theme === 'vibrant' ? 'text-white/80' : 'text-blue-600'}`}>{typeGroup.weeklyDelivered || ''}</td>
<td className={`p-3 text-center border-r border-gray-100 font-bold bg-orange-50/5 ${theme === 'vibrant' ? 'text-white/80' : 'text-orange-600'}`}>{typeGroup.weeklyReturned || ''}</td>
<td className={`p-3 text-center font-bold bg-purple-50/5 ${theme === 'vibrant' ? 'text-white/80' : 'text-purple-600'}`}>{typeGroup.weeklyReplaced || ''}</td>
</>
)}
</tr>
<AnimatePresence>
{expandedAssetTypes.has(typeGroup.type) &&
typeGroup.models.map((model) => (
<React.Fragment key={model.model}>
<motion.tr
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer ${expandedModels.has(model.model) ? 'bg-blue-50/10' : ''}`}
onClick={() => toggleModel(model.model)}
>
<td className="p-3 border-r border-gray-100 text-gray-300 text-center italic">{typeGroup.type}</td>
<td className="p-3 border-r border-gray-100 flex items-center 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>
</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' }); }}
className="text-blue-500 hover:underline font-medium"
>{model.total}</button>
</td>
<td className="p-3 text-center border-r border-gray-100">
{model.inventory > 0 ? (
<button
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory' }); }}
className="text-blue-500 hover:underline font-medium"
>{model.inventory}</button>
) : model.inventory}
</td>
{['嘉兴', '广东', '北京', '新疆', '其他'].map((reg) => (
<td key={reg} className="p-3 text-center border-r border-gray-100">
{(model.inventoryRegions[reg] || 0) > 0 ? (
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory' });
}}
className="text-blue-500 hover:underline font-medium"
>
{model.inventoryRegions[reg]}
</button>
) : (
''
)}
</td>
))}
<td className="p-3 text-center border-r border-gray-100">
{model.pending > 0 ? (
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending' });
}}
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">
{model.operating}
</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' });
}}
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' });
}}
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' });
}}
className="text-blue-500 hover:underline font-medium"
>
{model.weeklyReplaced}
</button>
) : (
model.weeklyReplaced
)}
</td>
</motion.tr>
</React.Fragment>
))}
</AnimatePresence>
</React.Fragment>
))}
</tbody>
</table>
</div>
{/* Mobile View Cards for Asset Summary */}
<div className="lg:hidden p-4 space-y-4">
{processedData.map((typeGroup) => (
<div key={typeGroup.type} className="space-y-3">
<div
className={`px-3 py-2 rounded flex justify-between items-center shadow-sm cursor-pointer transition-all ${
theme === 'vibrant'
? 'bg-blue-600 text-white active:bg-blue-700'
: theme === 'minimal'
? 'bg-white border-l-4 border-blue-500 text-gray-800 active:bg-gray-50'
: 'bg-blue-50 border border-blue-100 text-blue-700 active:bg-blue-100'
}`}
onClick={() => toggleAssetType(typeGroup.type)}
>
<div className="flex items-center gap-2">
{expandedAssetTypes.has(typeGroup.type) ? (
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} />
) : (
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-blue-300'} />
)}
<span className="text-xs font-bold">{typeGroup.type}</span>
</div>
<div className={`flex gap-3 text-[9px] font-normal ${theme === 'vibrant' ? 'opacity-90' : 'text-gray-500'}`}>
<span>
<span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span>
</span>
<span>
{' '}
<span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span>
</span>
<span>
{' '}
<span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span>
</span>
</div>
</div>
<AnimatePresence>
{expandedAssetTypes.has(typeGroup.type) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="space-y-3 overflow-hidden"
>
{typeGroup.models.map((model) => (
<div key={model.model} className="bg-white rounded-lg border border-gray-100 shadow-sm overflow-hidden">
<div
className="p-3 flex justify-between items-center cursor-pointer active:bg-gray-50"
onClick={() => toggleModel(model.model)}
>
<div className="flex items-center gap-2">
{expandedModels.has(model.model) ? (
<ChevronDown size={14} className="text-blue-500" />
) : (
<ChevronRight size={14} className="text-gray-300" />
)}
<span className="text-xs font-bold text-gray-700">{model.model}</span>
</div>
<div className="flex gap-2">
<button
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All' }); }}
className="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-bold active:bg-blue-100"
>
{model.total}
</button>
<span className="text-[10px] bg-green-50 text-green-600 px-1.5 py-0.5 rounded font-bold">
{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' })}
>
<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' })
}
>
<span className="text-[10px] text-gray-400"></span>
<span className="text-xs font-bold text-gray-600">{model.pending}</span>
</div>
<div className="col-span-2 grid grid-cols-5 gap-1 py-2 border-y border-gray-100">
{['嘉兴', '广东', '北京', '新疆', '其他'].map((reg) => (
<div key={reg} className="text-center">
<div className="text-[8px] text-gray-400 mb-0.5">
{reg === '嘉兴' ? '浙' : reg === '广东' ? '粤' : reg === '北京' ? '京' : reg === '新疆' ? '新' : '其'}
</div>
{(model.inventoryRegions[reg] || 0) > 0 ? (
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory' });
}}
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' })
}
>
<span className="text-[8px] text-gray-400 mb-1"></span>
<span className="text-xs font-bold text-blue-600">{model.weeklyDelivered}</span>
</div>
<div
className="bg-orange-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-orange-100/50 transition-colors"
onClick={() =>
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned' })
}
>
<span className="text-[8px] text-gray-400 mb-1"></span>
<span className="text-xs font-bold text-orange-600">{model.weeklyReturned}</span>
</div>
<div
className="bg-purple-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-purple-100/50 transition-colors"
onClick={() =>
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced' })
}
>
<span className="text-[8px] text-gray-400 mb-1"></span>
<span className="text-xs font-bold text-purple-600">{model.weeklyReplaced}</span>
</div>
</div>
</div>
</div>
)}
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
</div>
{/* Plate Number Modal */}
<AnimatePresence>
{showPlateNumbers && (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-white rounded-lg shadow-xl w-full max-w-md overflow-hidden"
>
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-blue-600 text-white">
<div>
<h3 className="font-bold text-sm">
{showPlateNumbers.batch === 'All' ? '全量' : showPlateNumbers.batch} -
</h3>
<p className="text-[10px] opacity-80">
{showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} |{' '}
{!showPlateNumbers.category
? '全部车辆'
: showPlateNumbers.category === 'Inventory'
? (showPlateNumbers.location === 'All' ? '库存' : `${showPlateNumbers.location}库存`)
: showPlateNumbers.category === 'Pending'
? '待交车'
: showPlateNumbers.category === 'Delivered'
? '本周已交车'
: showPlateNumbers.category === 'Returned'
? '已还车'
: '已替换'}
</p>
</div>
<button onClick={() => setShowPlateNumbers(null)} className="hover:bg-white/20 p-1 rounded">
<PlusCircle className="rotate-45" size={20} />
</button>
</div>
<div className="p-4 max-h-[400px] overflow-y-auto">
{modalLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="animate-spin text-blue-500" size={24} />
</div>
) : modalWeeklyDetail.length > 0 ? (
<div className="space-y-1.5">
{modalWeeklyDetail.map((v, i) => (
<div key={`${v.truck_id}-${i}`} className="flex items-center justify-between bg-gray-50 px-3 py-2 rounded border border-gray-100">
<span className="font-mono text-[11px] sm:text-xs font-bold text-gray-700">{v.plate_number}</span>
<div className="flex items-center gap-2 text-[10px] text-gray-400">
{v.customer_name && <span>{v.customer_name}</span>}
{v.handover_date && <span>{v.handover_date}</span>}
</div>
</div>
))}
</div>
) : modalVehicles.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{modalVehicles.map((v) => (
<div
key={v.id}
className="bg-gray-50 p-2 rounded border border-gray-100 text-center font-mono text-[11px] sm:text-xs font-bold text-gray-700"
>
{v.plateNumber}
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
<div className="p-3 bg-gray-50 border-t border-gray-100 flex justify-between items-center">
<span className="text-[10px] text-gray-400"> {modalWeeklyDetail.length > 0 ? modalWeeklyDetail.length : modalVehicles.length} </span>
<button
onClick={() => setShowPlateNumbers(null)}
className="px-4 py-1.5 bg-white border border-gray-200 rounded text-xs font-medium hover:bg-gray-100"
>
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
{/* Footer / Navigation */}
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden">
<button className="flex flex-col items-center text-blue-600">
<Activity size={20} />
<span className="text-[10px] mt-1"></span>
</button>
<button className="flex flex-col items-center text-gray-400">
<Warehouse size={20} />
<span className="text-[10px] mt-1"></span>
</button>
<button className="flex flex-col items-center text-gray-400">
<History size={20} />
<span className="text-[10px] mt-1"></span>
</button>
</div>
</div>
);
}