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:
618
src/App.tsx
618
src/App.tsx
@@ -15,8 +15,8 @@ import {
|
|||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats } from './types';
|
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
|
||||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats } from './api';
|
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats } from './api';
|
||||||
import type { WeeklyDetailItem } from './api';
|
import type { WeeklyDetailItem } from './api';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -35,6 +35,7 @@ export default function App() {
|
|||||||
isTrailer?: boolean;
|
isTrailer?: boolean;
|
||||||
type?: string;
|
type?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
|
title?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
@@ -68,22 +69,43 @@ export default function App() {
|
|||||||
const [customerFilters, setCustomerFilters] = useState({ customer: '', brand: '', department: '', manager: '', region: '' });
|
const [customerFilters, setCustomerFilters] = useState({ customer: '', brand: '', department: '', manager: '', region: '' });
|
||||||
const [isCustomerFilterOpen, setIsCustomerFilterOpen] = useState(false);
|
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 () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const [s, byType, dept, region, cust] = await Promise.all([
|
const [s, byType, dept, region, cust, inv] = await Promise.all([
|
||||||
fetchSummary(),
|
fetchSummary(),
|
||||||
fetchByType(),
|
fetchByType(),
|
||||||
fetchDeptStats(),
|
fetchDeptStats(),
|
||||||
fetchRegionStats(),
|
fetchRegionStats(),
|
||||||
fetchCustomerStats(),
|
fetchCustomerStats(),
|
||||||
|
fetchInventoryStats(),
|
||||||
]);
|
]);
|
||||||
setSummary(s);
|
setSummary(s);
|
||||||
setProcessedData(byType);
|
setProcessedData(byType);
|
||||||
setDeptData(dept);
|
setDeptData(dept);
|
||||||
setRegionData(region);
|
setRegionData(region);
|
||||||
setCustomerData(cust);
|
setCustomerData(cust);
|
||||||
|
setInventoryData(inv);
|
||||||
setLastUpdate(new Date().toLocaleString('zh-CN'));
|
setLastUpdate(new Date().toLocaleString('zh-CN'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : '数据加载失败');
|
setError(e instanceof Error ? e.message : '数据加载失败');
|
||||||
@@ -212,6 +234,49 @@ export default function App() {
|
|||||||
setExpandedCustomers(newSet);
|
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
|
// 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 allManagersList = deptData.flatMap((d) => d.managers.map((m) => m.manager)).filter((v, i, a) => a.indexOf(v) === i).sort();
|
||||||
const managerStats = deptData
|
const managerStats = deptData
|
||||||
@@ -236,6 +301,20 @@ export default function App() {
|
|||||||
// Derived data for region section
|
// Derived data for region section
|
||||||
const filteredRegionData = regionData.filter((r) => !regionFilters.region || r.region === regionFilters.region);
|
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) {
|
if (loading && !summary) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
||||||
@@ -859,6 +938,415 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Department Operations Statistics */}
|
||||||
<section className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden mb-6">
|
<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>
|
<div>
|
||||||
<h3 className="font-bold text-base flex items-center gap-2">
|
<h3 className="font-bold text-base flex items-center gap-2">
|
||||||
<Truck size={18} className="text-blue-400" />
|
<Truck size={18} className="text-blue-400" />
|
||||||
{showPlateNumbers.manager ? `${showPlateNumbers.manager} 的${showPlateNumbers.type || ''}车辆` :
|
{showPlateNumbers.title || (
|
||||||
showPlateNumbers.customer ? `${showPlateNumbers.customer} 的${showPlateNumbers.type || ''}车辆` :
|
(showPlateNumbers.manager ? `${showPlateNumbers.manager} 的${showPlateNumbers.type || ''}车辆` :
|
||||||
showPlateNumbers.batch === 'All' ? '全量批次' : `${showPlateNumbers.batch} 批次`} - 运营明细
|
showPlateNumbers.customer ? `${showPlateNumbers.customer} 的${showPlateNumbers.type || ''}车辆` :
|
||||||
|
showPlateNumbers.batch === 'All' ? '全量批次' : `${showPlateNumbers.batch} 批次`) + ' - 运营明细'
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] opacity-60 mt-0.5">
|
<p className="text-[10px] opacity-60 mt-0.5">
|
||||||
{showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} |
|
{showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} |
|
||||||
@@ -2030,6 +2520,114 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="flex-1 overflow-auto p-0 sm:p-4 bg-gray-50 min-h-0 overscroll-contain">
|
||||||
{modalLoading ? (
|
{modalLoading ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
@@ -2046,7 +2644,7 @@ export default function App() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="text-[11px]">
|
<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'}`}>
|
<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 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>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="text-[11px]">
|
<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'}`}>
|
<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' ? (
|
{showPlateNumbers.source === 'customer' ? (
|
||||||
<>
|
<>
|
||||||
@@ -2131,7 +2729,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{modalVehicles.length === 0 && (
|
{filteredModalVehicles.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={showPlateNumbers.source === 'customer' ? 14 : (showPlateNumbers.source === 'asset' ? 4 : 5)} className="p-8 text-center text-gray-400 italic">
|
<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="p-4 bg-white border-t border-gray-100 flex justify-between items-center shrink-0">
|
||||||
<div className="text-xs text-gray-500">
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPlateNumbers(null)}
|
onClick={() => setShowPlateNumbers(null)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
DeptGroup,
|
DeptGroup,
|
||||||
RegionGroup,
|
RegionGroup,
|
||||||
CustomerStats,
|
CustomerStats,
|
||||||
|
RegionalInventoryStats,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const BASE = '/api/vehicles';
|
const BASE = '/api/vehicles';
|
||||||
@@ -69,6 +70,10 @@ export async function fetchCustomerStats(): Promise<CustomerStats[]> {
|
|||||||
return fetchJson<CustomerStats[]>(`${BASE}/customer-stats`);
|
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[]> {
|
export async function fetchWeeklyDetail(type: string): Promise<WeeklyDetailItem[]> {
|
||||||
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?type=${type}`);
|
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
|
// GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending
|
||||||
app.get('/weekly-detail', async (c) => {
|
app.get('/weekly-detail', async (c) => {
|
||||||
const type = c.req.query('type');
|
const type = c.req.query('type');
|
||||||
|
|||||||
10
src/types.ts
10
src/types.ts
@@ -114,6 +114,16 @@ export interface VehicleListItem {
|
|||||||
orgName: string | null;
|
orgName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegionalInventoryStats {
|
||||||
|
region: string;
|
||||||
|
city: string;
|
||||||
|
brand: string;
|
||||||
|
type: string;
|
||||||
|
model: string;
|
||||||
|
batch: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ManagerStats {
|
export interface ManagerStats {
|
||||||
manager: string;
|
manager: string;
|
||||||
department: string;
|
department: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user