feat: add inventory statistics section, adapt to latest prototype
- Add GET /api/vehicles/inventory-stats endpoint that groups inventory vehicles by macro-region, city, brand, type, model, and batch - Add RegionalInventoryStats type and fetchInventoryStats API function - Add full inventory statistics section with region/model tabs, filters, desktop table, and mobile views - Add modal filters (plate number, model, brand, location search) to vehicle detail modal - Update modal header to support title field for contextual naming Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
616
src/App.tsx
616
src/App.tsx
@@ -15,8 +15,8 @@ import {
|
||||
ArrowRightLeft,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats } from './types';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats } from './api';
|
||||
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats } from './api';
|
||||
import type { WeeklyDetailItem } from './api';
|
||||
|
||||
export default function App() {
|
||||
@@ -35,6 +35,7 @@ export default function App() {
|
||||
isTrailer?: boolean;
|
||||
type?: string;
|
||||
source?: string;
|
||||
title?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Data state
|
||||
@@ -68,22 +69,43 @@ export default function App() {
|
||||
const [customerFilters, setCustomerFilters] = useState({ customer: '', brand: '', department: '', manager: '', region: '' });
|
||||
const [isCustomerFilterOpen, setIsCustomerFilterOpen] = useState(false);
|
||||
|
||||
// Inventory statistics section state
|
||||
const [inventoryData, setInventoryData] = useState<RegionalInventoryStats[]>([]);
|
||||
const [inventoryTab, setInventoryTab] = useState<'region' | 'model'>('region');
|
||||
const [expandedInventoryRegions, setExpandedInventoryRegions] = useState<Set<string>>(new Set());
|
||||
const [expandedInventoryTypes, setExpandedInventoryTypes] = useState<Set<string>>(new Set(['4.5T普货']));
|
||||
const [inventoryFilters, setInventoryFilters] = useState({ region: '', city: '', brand: '', batch: '', model: '' });
|
||||
const [isInventoryFilterOpen, setIsInventoryFilterOpen] = useState(false);
|
||||
|
||||
// Modal filter state
|
||||
const [modalFilters, setModalFilters] = useState({ plateNumber: '', model: '', brand: '', location: '' });
|
||||
const [isModalFilterExpanded, setIsModalFilterExpanded] = useState(false);
|
||||
|
||||
// Reset modal filters when modal opens
|
||||
useEffect(() => {
|
||||
if (showPlateNumbers) {
|
||||
setModalFilters({ plateNumber: '', model: '', brand: '', location: '' });
|
||||
}
|
||||
}, [showPlateNumbers]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [s, byType, dept, region, cust] = await Promise.all([
|
||||
const [s, byType, dept, region, cust, inv] = await Promise.all([
|
||||
fetchSummary(),
|
||||
fetchByType(),
|
||||
fetchDeptStats(),
|
||||
fetchRegionStats(),
|
||||
fetchCustomerStats(),
|
||||
fetchInventoryStats(),
|
||||
]);
|
||||
setSummary(s);
|
||||
setProcessedData(byType);
|
||||
setDeptData(dept);
|
||||
setRegionData(region);
|
||||
setCustomerData(cust);
|
||||
setInventoryData(inv);
|
||||
setLastUpdate(new Date().toLocaleString('zh-CN'));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '数据加载失败');
|
||||
@@ -212,6 +234,49 @@ export default function App() {
|
||||
setExpandedCustomers(newSet);
|
||||
};
|
||||
|
||||
const toggleInventoryRegion = (region: string) => {
|
||||
const newSet = new Set(expandedInventoryRegions);
|
||||
if (newSet.has(region)) newSet.delete(region);
|
||||
else newSet.add(region);
|
||||
setExpandedInventoryRegions(newSet);
|
||||
};
|
||||
|
||||
const toggleInventoryType = (type: string) => {
|
||||
const newSet = new Set(expandedInventoryTypes);
|
||||
if (newSet.has(type)) newSet.delete(type);
|
||||
else newSet.add(type);
|
||||
setExpandedInventoryTypes(newSet);
|
||||
};
|
||||
|
||||
// Derived data for inventory section
|
||||
const filteredInventoryStats = inventoryData.filter((s) => {
|
||||
const mr = !inventoryFilters.region || s.region === inventoryFilters.region;
|
||||
const mc = !inventoryFilters.city || s.city === inventoryFilters.city;
|
||||
const mb = !inventoryFilters.brand || s.brand === inventoryFilters.brand;
|
||||
const mbt = !inventoryFilters.batch || s.batch === inventoryFilters.batch;
|
||||
const mm = !inventoryFilters.model || s.model.toLowerCase().includes(inventoryFilters.model.toLowerCase());
|
||||
return mr && mc && mb && mbt && mm;
|
||||
});
|
||||
|
||||
const uniqueInventoryBrands = Array.from(new Set(inventoryData.map((s) => s.brand).filter(Boolean)));
|
||||
const uniqueInventoryRegions = Array.from(new Set(inventoryData.map((s) => s.region)));
|
||||
const uniqueInventoryCities = Array.from(new Set(inventoryData.map((s) => s.city).filter(Boolean)));
|
||||
const uniqueInventoryBatches = Array.from(new Set(inventoryData.map((s) => s.batch).filter(Boolean)));
|
||||
|
||||
const inventoryByRegion: Record<string, Record<string, RegionalInventoryStats[]>> = {};
|
||||
for (const s of filteredInventoryStats) {
|
||||
if (!inventoryByRegion[s.region]) inventoryByRegion[s.region] = {};
|
||||
if (!inventoryByRegion[s.region][s.city]) inventoryByRegion[s.region][s.city] = [];
|
||||
inventoryByRegion[s.region][s.city].push(s);
|
||||
}
|
||||
|
||||
const inventoryByModel: Record<string, Record<string, RegionalInventoryStats[]>> = {};
|
||||
for (const s of filteredInventoryStats) {
|
||||
if (!inventoryByModel[s.type]) inventoryByModel[s.type] = {};
|
||||
if (!inventoryByModel[s.type][s.model]) inventoryByModel[s.type][s.model] = [];
|
||||
inventoryByModel[s.type][s.model].push(s);
|
||||
}
|
||||
|
||||
// Derived data for dept section
|
||||
const allManagersList = deptData.flatMap((d) => d.managers.map((m) => m.manager)).filter((v, i, a) => a.indexOf(v) === i).sort();
|
||||
const managerStats = deptData
|
||||
@@ -236,6 +301,20 @@ export default function App() {
|
||||
// Derived data for region section
|
||||
const filteredRegionData = regionData.filter((r) => !regionFilters.region || r.region === regionFilters.region);
|
||||
|
||||
// Filtered modal vehicles based on modal filters
|
||||
const filteredModalVehicles = modalVehicles.filter((v) => {
|
||||
const mp = !modalFilters.plateNumber || v.plateNumber.toLowerCase().includes(modalFilters.plateNumber.toLowerCase());
|
||||
const mm = !modalFilters.model || v.model.toLowerCase().includes(modalFilters.model.toLowerCase());
|
||||
const mb = !modalFilters.brand || (v.brandLabel || '').toLowerCase().includes(modalFilters.brand.toLowerCase());
|
||||
const ml = !modalFilters.location || v.location.toLowerCase().includes(modalFilters.location.toLowerCase());
|
||||
return mp && mm && mb && ml;
|
||||
});
|
||||
|
||||
const filteredModalWeeklyDetail = modalWeeklyDetail.filter((v) => {
|
||||
const mp = !modalFilters.plateNumber || v.plate_number.toLowerCase().includes(modalFilters.plateNumber.toLowerCase());
|
||||
return mp;
|
||||
});
|
||||
|
||||
if (loading && !summary) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
||||
@@ -859,6 +938,415 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inventory Statistics */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm mb-6 overflow-hidden">
|
||||
<div className="p-3 sm:p-4 border-b border-gray-50 bg-white flex items-center justify-between relative">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1.5 h-6 bg-blue-600 rounded-full"></div>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-lg font-bold text-gray-800 leading-tight">库存统计</h2>
|
||||
<span className="text-[10px] text-gray-400 font-medium">实时更新库存数据</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 ml-auto pr-2">
|
||||
<div className="flex flex-col items-end cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
|
||||
<span className="text-[10px] text-gray-400 font-bold tracking-wider uppercase mb-0.5">总库存数据</span>
|
||||
<span className="text-2xl font-black text-gray-900 leading-none">
|
||||
{filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)}
|
||||
<span className="text-xs font-bold text-gray-400 ml-1">台</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => 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.batch || 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.batch || inventoryFilters.model) && (
|
||||
<span className="w-2 h-2 bg-white rounded-full animate-pulse"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isInventoryFilterOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" 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="absolute right-0 top-full mt-2 w-72 bg-white rounded-xl shadow-2xl border border-slate-100 z-50 p-4"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xs font-bold text-slate-800">库存统计 - 数据筛选</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setInventoryFilters({ region: '', city: '', brand: '', batch: '', model: '' })}
|
||||
className="text-[10px] text-blue-500 hover:underline"
|
||||
>
|
||||
重置所有
|
||||
</button>
|
||||
<button onClick={() => setIsInventoryFilterOpen(false)} className="text-slate-400 hover:text-slate-600">
|
||||
<PlusCircle className="rotate-45" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-left">
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">区域</label>
|
||||
<select
|
||||
value={inventoryFilters.region}
|
||||
onChange={(e) => setInventoryFilters({...inventoryFilters, 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={inventoryFilters.city}
|
||||
onChange={(e) => setInventoryFilters({...inventoryFilters, 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={inventoryFilters.brand}
|
||||
onChange={(e) => setInventoryFilters({...inventoryFilters, 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={inventoryFilters.batch}
|
||||
onChange={(e) => setInventoryFilters({...inventoryFilters, batch: 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>
|
||||
{uniqueInventoryBatches.map(b => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">车型名称</label>
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索车型..."
|
||||
value={inventoryFilters.model}
|
||||
onChange={(e) => setInventoryFilters({...inventoryFilters, model: e.target.value})}
|
||||
className="w-full text-xs bg-white border border-slate-200 rounded-lg pl-7 pr-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
|
||||
<div className="flex bg-gray-200/50 p-1 rounded-lg w-fit shadow-inner">
|
||||
<button
|
||||
onClick={() => setInventoryTab('region')}
|
||||
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
|
||||
inventoryTab === 'region' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
按区域
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInventoryTab('model')}
|
||||
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
|
||||
inventoryTab === 'model' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
按车型
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Bar */}
|
||||
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.batch || inventoryFilters.model) && (
|
||||
<div className="px-4 py-2 bg-white border-b border-slate-50 flex flex-wrap gap-2 items-center">
|
||||
<span className="text-[10px] text-slate-400 mr-1">当前筛选:</span>
|
||||
{inventoryFilters.region && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-[10px] flex items-center gap-1">
|
||||
区域: {inventoryFilters.region}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, region: ''})} className="hover:text-blue-800">×</button>
|
||||
</span>
|
||||
)}
|
||||
{inventoryFilters.city && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-[10px] flex items-center gap-1">
|
||||
城市: {inventoryFilters.city}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, city: ''})} className="hover:text-blue-800">×</button>
|
||||
</span>
|
||||
)}
|
||||
{inventoryFilters.brand && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-[10px] flex items-center gap-1">
|
||||
品牌: {inventoryFilters.brand}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, brand: ''})} className="hover:text-blue-800">×</button>
|
||||
</span>
|
||||
)}
|
||||
{inventoryFilters.batch && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-[10px] flex items-center gap-1">
|
||||
批次: {inventoryFilters.batch}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, batch: ''})} className="hover:text-blue-800">×</button>
|
||||
</span>
|
||||
)}
|
||||
{inventoryFilters.model && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-[10px] flex items-center gap-1">
|
||||
车型: {inventoryFilters.model}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, model: ''})} className="hover:text-blue-800">×</button>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setInventoryFilters({ region: '', city: '', brand: '', batch: '', model: '' })}
|
||||
className="text-[10px] text-slate-400 hover:text-red-500 ml-auto"
|
||||
>
|
||||
清除全部
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop View Table */}
|
||||
<div className="hidden lg:block overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 text-[11px] text-slate-500 uppercase tracking-wider border-b border-slate-100">
|
||||
<th className="p-3 font-semibold w-64">{inventoryTab === 'region' ? '区域 / 城市' : '车型分类 / 型号'}</th>
|
||||
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '品牌' : '品牌'}</th>
|
||||
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '车型' : '所在区域/城市'}</th>
|
||||
<th className="p-3 font-semibold text-center w-32">库存数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-xs">
|
||||
{inventoryTab === 'region' ? (
|
||||
Object.entries(inventoryByRegion).map(([region, cities]) => (
|
||||
<React.Fragment key={region}>
|
||||
<tr
|
||||
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||
onClick={() => toggleInventoryRegion(region)}
|
||||
>
|
||||
<td colSpan={4} className="p-3 font-bold text-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span>{region}</span>
|
||||
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: region, category: 'Inventory', source: 'asset', title: `库存统计 - ${region}` });
|
||||
}}>
|
||||
(共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<AnimatePresence>
|
||||
{expandedInventoryRegions.has(region) && Object.entries(cities).map(([city, stats]) => (
|
||||
<React.Fragment key={city}>
|
||||
{stats.map((stat, idx) => (
|
||||
<motion.tr
|
||||
key={`${city}-${stat.model}-${idx}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
|
||||
>
|
||||
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
|
||||
{idx === 0 ? <span className="font-medium text-slate-600">{city}</span> : ''}
|
||||
</td>
|
||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
|
||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.model}</td>
|
||||
<td className="p-3 text-center font-bold text-blue-600">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` });
|
||||
}}
|
||||
className="text-blue-600 hover:underline font-bold"
|
||||
>
|
||||
{stat.quantity}
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
Object.entries(inventoryByModel).map(([type, models]) => (
|
||||
<React.Fragment key={type}>
|
||||
<tr
|
||||
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||
onClick={() => toggleInventoryType(type)}
|
||||
>
|
||||
<td colSpan={4} className="p-3 font-bold text-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span>{type}</span>
|
||||
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', type: type, category: 'Inventory', source: 'asset', title: `库存统计 - ${type}` });
|
||||
}}>
|
||||
(共 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<AnimatePresence>
|
||||
{expandedInventoryTypes.has(type) && Object.entries(models).map(([model, stats]) => (
|
||||
<React.Fragment key={model}>
|
||||
{stats.map((stat, idx) => (
|
||||
<motion.tr
|
||||
key={`${model}-${stat.region}-${stat.city}-${idx}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
|
||||
>
|
||||
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
|
||||
{idx === 0 ? <span className="font-medium text-slate-600">{model}</span> : ''}
|
||||
</td>
|
||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
|
||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.region} / {stat.city}</td>
|
||||
<td className="p-3 text-center font-bold text-blue-600">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` });
|
||||
}}
|
||||
className="text-blue-600 hover:underline font-bold"
|
||||
>
|
||||
{stat.quantity}
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile View */}
|
||||
<div className="lg:hidden p-3 space-y-3">
|
||||
{inventoryTab === 'region' ? (
|
||||
Object.entries(inventoryByRegion).map(([region, cities]) => (
|
||||
<div key={region} className="border border-slate-100 rounded overflow-hidden">
|
||||
<div
|
||||
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleInventoryRegion(region)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span className="text-xs font-bold text-slate-700">{region}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-blue-600">
|
||||
{Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInventoryRegions.has(region) && (
|
||||
<div className="p-2 space-y-2 bg-white">
|
||||
{Object.entries(cities).map(([city, stats]) => (
|
||||
<div key={city} className="border-l-2 border-slate-200 pl-3 py-1">
|
||||
<div className="text-[10px] font-bold text-slate-500 mb-2">{city}</div>
|
||||
<div className="space-y-2">
|
||||
{stats.map((stat, idx) => (
|
||||
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
|
||||
<span className="text-slate-700 font-medium">{stat.model}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` })}
|
||||
className="font-bold text-blue-600 hover:underline"
|
||||
>
|
||||
{stat.quantity} 台
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
Object.entries(inventoryByModel).map(([type, models]) => (
|
||||
<div key={type} className="border border-slate-100 rounded overflow-hidden">
|
||||
<div
|
||||
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleInventoryType(type)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span className="text-xs font-bold text-slate-700">{type}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-blue-600">
|
||||
{Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInventoryTypes.has(type) && (
|
||||
<div className="p-2 space-y-2 bg-white">
|
||||
{Object.entries(models).map(([model, stats]) => (
|
||||
<div key={model} className="border-l-2 border-slate-200 pl-3 py-1">
|
||||
<div className="text-[10px] font-bold text-slate-500 mb-2">{model}</div>
|
||||
<div className="space-y-2">
|
||||
{stats.map((stat, idx) => (
|
||||
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
|
||||
<span className="text-slate-700 font-medium">{stat.region} / {stat.city}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` })}
|
||||
className="font-bold text-blue-600 hover:underline"
|
||||
>
|
||||
{stat.quantity} 台
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Department Operations Statistics */}
|
||||
<section className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden mb-6">
|
||||
@@ -2011,9 +2499,11 @@ export default function App() {
|
||||
<div>
|
||||
<h3 className="font-bold text-base flex items-center gap-2">
|
||||
<Truck size={18} className="text-blue-400" />
|
||||
{showPlateNumbers.manager ? `${showPlateNumbers.manager} 的${showPlateNumbers.type || ''}车辆` :
|
||||
{showPlateNumbers.title || (
|
||||
(showPlateNumbers.manager ? `${showPlateNumbers.manager} 的${showPlateNumbers.type || ''}车辆` :
|
||||
showPlateNumbers.customer ? `${showPlateNumbers.customer} 的${showPlateNumbers.type || ''}车辆` :
|
||||
showPlateNumbers.batch === 'All' ? '全量批次' : `${showPlateNumbers.batch} 批次`} - 运营明细
|
||||
showPlateNumbers.batch === 'All' ? '全量批次' : `${showPlateNumbers.batch} 批次`) + ' - 运营明细'
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-[10px] opacity-60 mt-0.5">
|
||||
{showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} |
|
||||
@@ -2030,6 +2520,114 @@ export default function App() {
|
||||
</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="relative w-40 sm:w-64" onClick={(e) => e.stopPropagation()}>
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={modalFilters.plateNumber}
|
||||
onChange={(e) => setModalFilters({...modalFilters, plateNumber: e.target.value})}
|
||||
placeholder="快速搜索车牌..."
|
||||
className="w-full text-[11px] pl-7 pr-2 py-1 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"
|
||||
/>
|
||||
</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>
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={modalFilters.plateNumber}
|
||||
onChange={(e) => setModalFilters({...modalFilters, plateNumber: e.target.value})}
|
||||
placeholder="搜索车牌..."
|
||||
className="w-full text-[11px] pl-7 pr-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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-[10px] text-gray-500 font-medium">车型型号</label>
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={modalFilters.model}
|
||||
onChange={(e) => setModalFilters({...modalFilters, model: e.target.value})}
|
||||
placeholder="搜索车型..."
|
||||
className="w-full text-[11px] pl-7 pr-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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-[10px] text-gray-500 font-medium">品牌名称</label>
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={modalFilters.brand}
|
||||
onChange={(e) => setModalFilters({...modalFilters, brand: e.target.value})}
|
||||
placeholder="搜索品牌..."
|
||||
className="w-full text-[11px] pl-7 pr-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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-[10px] text-gray-500 font-medium">运营所在地</label>
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={modalFilters.location}
|
||||
onChange={(e) => setModalFilters({...modalFilters, location: e.target.value})}
|
||||
placeholder="搜索所在地..."
|
||||
className="w-full text-[11px] pl-7 pr-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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pb-2">
|
||||
<button
|
||||
onClick={() => setModalFilters({ plateNumber: '', model: '', brand: '', location: '' })}
|
||||
className="text-[10px] text-blue-500 hover:text-blue-600 font-medium"
|
||||
>
|
||||
重置筛选
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-0 sm:p-4 bg-gray-50 min-h-0 overscroll-contain">
|
||||
{modalLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
@@ -2046,7 +2644,7 @@ export default function App() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-[11px]">
|
||||
{modalWeeklyDetail.map((v, i) => (
|
||||
{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">{v.plate_number}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.customer_name || '—'}</td>
|
||||
@@ -2092,7 +2690,7 @@ export default function App() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-[11px]">
|
||||
{modalVehicles.map((v, idx) => (
|
||||
{filteredModalVehicles.map((v, idx) => (
|
||||
<tr key={v.id} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
|
||||
{showPlateNumbers.source === 'customer' ? (
|
||||
<>
|
||||
@@ -2131,7 +2729,7 @@ export default function App() {
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{modalVehicles.length === 0 && (
|
||||
{filteredModalVehicles.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={showPlateNumbers.source === 'customer' ? 14 : (showPlateNumbers.source === 'asset' ? 4 : 5)} className="p-8 text-center text-gray-400 italic">
|
||||
暂无符合条件的车辆数据
|
||||
@@ -2146,7 +2744,7 @@ export default function App() {
|
||||
|
||||
<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">{modalWeeklyDetail.length > 0 ? modalWeeklyDetail.length : modalVehicles.length}</span> 台车辆
|
||||
共计 <span className="font-bold text-blue-600">{filteredModalWeeklyDetail.length > 0 ? filteredModalWeeklyDetail.length : filteredModalVehicles.length}</span> 台车辆
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPlateNumbers(null)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
DeptGroup,
|
||||
RegionGroup,
|
||||
CustomerStats,
|
||||
RegionalInventoryStats,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api/vehicles';
|
||||
@@ -69,6 +70,10 @@ export async function fetchCustomerStats(): Promise<CustomerStats[]> {
|
||||
return fetchJson<CustomerStats[]>(`${BASE}/customer-stats`);
|
||||
}
|
||||
|
||||
export async function fetchInventoryStats(): Promise<RegionalInventoryStats[]> {
|
||||
return fetchJson<RegionalInventoryStats[]>(`${BASE}/inventory-stats`);
|
||||
}
|
||||
|
||||
export async function fetchWeeklyDetail(type: string): Promise<WeeklyDetailItem[]> {
|
||||
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?type=${type}`);
|
||||
}
|
||||
|
||||
@@ -813,6 +813,43 @@ app.get('/list', async (c) => {
|
||||
);
|
||||
});
|
||||
|
||||
// GET /api/vehicles/inventory-stats — grouped inventory stats for the inventory statistics section
|
||||
app.get('/inventory-stats', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
const inventory = vehicles.filter((v) => v.status === 'Inventory');
|
||||
|
||||
const TYPE_NAME_MAP: Record<string, string> = {
|
||||
t4_5: '4.5T普货',
|
||||
t4_5c: '4.5T冷链',
|
||||
t18: '18T',
|
||||
t49: '49T',
|
||||
trailer: '挂车',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
const groups = new Map<string, number>();
|
||||
for (const v of inventory) {
|
||||
const typeCategory = classifyVehicleType(v);
|
||||
const typeName = TYPE_NAME_MAP[typeCategory];
|
||||
const region = mapMacroRegion(v.province, v.city);
|
||||
const city = v.city || '其他';
|
||||
const brand = v.brandLabel || '未知';
|
||||
const model = v.model;
|
||||
const batch = v.contractNo || 'N/A';
|
||||
const key = `${region}|${city}|${brand}|${typeName}|${model}|${batch}`;
|
||||
groups.set(key, (groups.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
const result = Array.from(groups.entries())
|
||||
.map(([key, quantity]) => {
|
||||
const [region, city, brand, type, model, batch] = key.split('|');
|
||||
return { region, city, brand, type, model, batch, quantity };
|
||||
})
|
||||
.sort((a, b) => b.quantity - a.quantity);
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending
|
||||
app.get('/weekly-detail', async (c) => {
|
||||
const type = c.req.query('type');
|
||||
|
||||
10
src/types.ts
10
src/types.ts
@@ -114,6 +114,16 @@ export interface VehicleListItem {
|
||||
orgName: string | null;
|
||||
}
|
||||
|
||||
export interface RegionalInventoryStats {
|
||||
region: string;
|
||||
city: string;
|
||||
brand: string;
|
||||
type: string;
|
||||
model: string;
|
||||
batch: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface ManagerStats {
|
||||
manager: string;
|
||||
department: string;
|
||||
|
||||
Reference in New Issue
Block a user