feat: tab导航、recharts图表、库存统计、出勤率里程、区域城市下钻、数据一致性修复
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 新增tab导航(总览/按部门/按区域/按客户)+ 移动端底部导航 - 新增recharts柱状图(区域分布)和饼图(客户分布) - 新增库存统计模块(按区域/按车型,筛选面板) - 对接ln_vehicle_day_mileage表实现出勤率和日均里程 - 区域运营支持区域→城市→车型三级下钻 - 修复ownership取字段错误(改用truck_rent_status) - 修复部门统计闲置定义(当日无行驶里程) - 修复区域图表"其他"重复问题(后端Top N合并) - 修复城市名空值降级(resolveCity取province) - 修复下钻数据不一致(统一category/vehicleType参数) - 扩展/list端点支持大区过滤和未分配匹配 - 所有筛选改为searchable datalist Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
250
src/App.tsx
250
src/App.tsx
@@ -32,7 +32,7 @@ import {
|
||||
LabelList,
|
||||
} from 'recharts';
|
||||
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats } from './api';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './api';
|
||||
import type { WeeklyDetailItem } from './api';
|
||||
|
||||
// --- Constants ---
|
||||
@@ -86,6 +86,7 @@ export default function App() {
|
||||
|
||||
// Region section state
|
||||
const [expandedRegions, setExpandedRegions] = useState<Set<string>>(new Set());
|
||||
const [expandedRegionCities, setExpandedRegionCities] = useState<Set<string>>(new Set());
|
||||
const [regionFilters, setRegionFilters] = useState({ region: '', city: '', customer: '' });
|
||||
const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false);
|
||||
|
||||
@@ -105,6 +106,7 @@ export default function App() {
|
||||
// Chart view states
|
||||
const [customerChartView, setCustomerChartView] = useState<'region' | 'city'>('region');
|
||||
const [regionChartView, setRegionChartView] = useState<'region' | 'city'>('region');
|
||||
const [regionChartData, setRegionChartData] = useState<{ name: string; value: number }[]>([]);
|
||||
|
||||
// Modal filter state
|
||||
const [modalFilters, setModalFilters] = useState({ plateNumber: '', model: '', brand: '', location: '' });
|
||||
@@ -149,6 +151,11 @@ export default function App() {
|
||||
return () => clearInterval(interval);
|
||||
}, [loadData]);
|
||||
|
||||
// Fetch region chart data when view changes
|
||||
useEffect(() => {
|
||||
fetchRegionChart(regionChartView, regionChartView === 'city' ? 5 : 8).then(setRegionChartData).catch(() => setRegionChartData([]));
|
||||
}, [regionChartView]);
|
||||
|
||||
// Load modal vehicles
|
||||
useEffect(() => {
|
||||
if (!showPlateNumbers) {
|
||||
@@ -185,24 +192,17 @@ export default function App() {
|
||||
if (showPlateNumbers.isColdChain !== undefined) params.isColdChain = String(showPlateNumbers.isColdChain);
|
||||
if (showPlateNumbers.isTrailer !== undefined) params.isTrailer = String(showPlateNumbers.isTrailer);
|
||||
}
|
||||
// Map prototype's type field to backend vehicleType
|
||||
// Map type field to backend vehicleType
|
||||
if (showPlateNumbers.type) {
|
||||
if (showPlateNumbers.type === '4.5T') {
|
||||
if (showPlateNumbers.isColdChain === true) {
|
||||
params.vehicleType = '4.5T冷链';
|
||||
} else if (showPlateNumbers.isColdChain === false) {
|
||||
params.vehicleType = '4.5T普货';
|
||||
}
|
||||
} else if (showPlateNumbers.type === '18T') {
|
||||
params.vehicleType = '18T';
|
||||
} else if (showPlateNumbers.type === '49T') {
|
||||
params.vehicleType = '49T';
|
||||
} else if (showPlateNumbers.type === '其他车型') {
|
||||
if (showPlateNumbers.isTrailer === true) {
|
||||
params.isTrailer = 'true';
|
||||
} else if (showPlateNumbers.isTrailer === false) {
|
||||
params.vehicleType = '其他';
|
||||
}
|
||||
const t = showPlateNumbers.type;
|
||||
if (t === '4.5T') {
|
||||
if (showPlateNumbers.isColdChain === true) params.vehicleType = '4.5T冷链';
|
||||
else if (showPlateNumbers.isColdChain === false) params.vehicleType = '4.5T普货';
|
||||
} else if (t === '4.5T普货' || t === '4.5T冷链' || t === '18T' || t === '49T' || t === '挂车' || t === '其他') {
|
||||
params.vehicleType = t;
|
||||
} else if (t === '其他车型') {
|
||||
if (showPlateNumbers.isTrailer === true) params.isTrailer = 'true';
|
||||
else if (showPlateNumbers.isTrailer === false) params.vehicleType = '其他';
|
||||
}
|
||||
}
|
||||
fetchVehicleList(params)
|
||||
@@ -256,6 +256,13 @@ export default function App() {
|
||||
setExpandedRegions(newSet);
|
||||
};
|
||||
|
||||
const toggleRegionCity = (key: string) => {
|
||||
const newSet = new Set(expandedRegionCities);
|
||||
if (newSet.has(key)) newSet.delete(key);
|
||||
else newSet.add(key);
|
||||
setExpandedRegionCities(newSet);
|
||||
};
|
||||
|
||||
const toggleCustomer = (customer: string) => {
|
||||
const newSet = new Set(expandedCustomers);
|
||||
if (newSet.has(customer)) newSet.delete(customer);
|
||||
@@ -477,7 +484,7 @@ export default function App() {
|
||||
<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>
|
||||
<span className="text-[8px] text-gray-400 leading-none">自{SUMMARY.operating.self} 租{SUMMARY.operating.leased}{SUMMARY.operating.hanging > 0 ? ` 挂${SUMMARY.operating.hanging}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1542,7 +1549,7 @@ export default function App() {
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] opacity-50 uppercase font-bold tracking-widest mb-0.5 text-blue-400">平均出勤</span>
|
||||
<span className="text-xl font-black text-blue-400">
|
||||
{'—'}
|
||||
{deptData.length > 0 ? (deptData.reduce((acc, d) => acc + d.attendanceRate, 0) / deptData.length).toFixed(1) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1561,7 +1568,7 @@ export default function App() {
|
||||
onClick={() => setDeptViewMode('manager')}
|
||||
className={`px-4 py-1.5 text-xs font-bold rounded-md transition-all ${deptViewMode === 'manager' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
按业务员
|
||||
按业务负责人
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1578,7 +1585,7 @@ export default function App() {
|
||||
className="w-full pl-9 pr-8 py-1.5 bg-white border border-gray-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm font-bold text-gray-700"
|
||||
/>
|
||||
<datalist id="dl-dept-manager">
|
||||
<option value="All">所有业务员</option>
|
||||
<option value="All">所有业务负责人</option>
|
||||
{allManagersList.map(m => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
@@ -1593,7 +1600,7 @@ export default function App() {
|
||||
<table className="w-full text-left border-collapse min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-100/50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-200">
|
||||
<th className="p-2 font-bold border-r border-gray-100 w-48">{deptViewMode === 'department' ? '部门名称' : '业务员'}</th>
|
||||
<th className="p-2 font-bold border-r border-gray-100 w-48">{deptViewMode === 'department' ? '部门名称' : '业务负责人'}</th>
|
||||
{deptViewMode === 'manager' && <th className="p-2 font-bold border-r border-gray-100 w-32">所属部门</th>}
|
||||
<th className="p-2 font-bold border-r border-gray-100 text-center w-24">{deptViewMode === 'department' ? '出勤率' : '合计资产'}</th>
|
||||
{deptViewMode === 'department' && (
|
||||
@@ -1634,7 +1641,7 @@ export default function App() {
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center">
|
||||
<span className="bg-blue-50 text-blue-600 text-[10px] font-bold px-2 py-0.5 rounded-full">
|
||||
{'—'}
|
||||
{dept.attendanceRate}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center font-black text-gray-800 text-sm">
|
||||
@@ -1642,7 +1649,7 @@ export default function App() {
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center">
|
||||
<div className="flex items-baseline justify-center gap-1">
|
||||
<span className="font-black text-gray-800 text-sm">{'—'}</span>
|
||||
<span className="font-black text-gray-800 text-sm">{dept.avgMileage}</span>
|
||||
<span className="text-[9px] text-gray-400 font-bold">km</span>
|
||||
</div>
|
||||
</td>
|
||||
@@ -1688,42 +1695,42 @@ export default function App() {
|
||||
<div className="grid grid-cols-3 gap-1 mt-2">
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">4.5T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t4_5}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">冷链</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t4_5c}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">18T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t18}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">49T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t49}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">挂车</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.trailer}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">其他</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.other}</div>
|
||||
@@ -1759,7 +1766,7 @@ export default function App() {
|
||||
className="p-2 border-r border-gray-100 text-center font-black text-blue-600 text-sm cursor-pointer hover:bg-blue-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, source: 'department' });
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, category: 'Operating', source: 'department' });
|
||||
}}
|
||||
>
|
||||
{m.total}
|
||||
@@ -1786,27 +1793,27 @@ export default function App() {
|
||||
<tr className="bg-gray-50/50 border-b border-gray-100">
|
||||
<td colSpan={10} className="p-0">
|
||||
<div className="grid grid-cols-6 text-[10px] bg-white/50">
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, source: 'department' })}>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department' })}>
|
||||
<span className="text-gray-400 uppercase mb-1">4.5T</span>
|
||||
<span className="font-bold text-gray-600">{m.t4_5}</span>
|
||||
</div>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, source: 'department' })}>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department' })}>
|
||||
<span className="text-gray-400 uppercase mb-1">冷链</span>
|
||||
<span className="font-bold text-gray-600">{m.t4_5c}</span>
|
||||
</div>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', source: 'department' })}>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department' })}>
|
||||
<span className="text-gray-400 uppercase mb-1">18T</span>
|
||||
<span className="font-bold text-gray-600">{m.t18}</span>
|
||||
</div>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', source: 'department' })}>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department' })}>
|
||||
<span className="text-gray-400 uppercase mb-1">49T</span>
|
||||
<span className="font-bold text-gray-600">{m.t49}</span>
|
||||
</div>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, source: 'department' })}>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department' })}>
|
||||
<span className="text-gray-400 uppercase mb-1">挂车</span>
|
||||
<span className="font-bold text-gray-600">{m.trailer}</span>
|
||||
</div>
|
||||
<div className="p-2 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, source: 'department' })}>
|
||||
<div className="p-2 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department' })}>
|
||||
<span className="text-gray-400 uppercase mb-1">其他</span>
|
||||
<span className="font-bold text-gray-600">{m.other}</span>
|
||||
</div>
|
||||
@@ -1836,7 +1843,7 @@ export default function App() {
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="text-sm font-bold text-gray-800">{dept.department}</h3>
|
||||
<span className="bg-blue-50 text-blue-600 text-[9px] font-bold px-2 py-0.5 rounded-full">
|
||||
出勤率: —
|
||||
出勤率: {dept.attendanceRate}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
@@ -1846,7 +1853,7 @@ export default function App() {
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-[8px] text-gray-400 uppercase font-bold mb-0.5">里程</div>
|
||||
<div className="text-xs font-black text-gray-800">{'—'}</div>
|
||||
<div className="text-xs font-black text-gray-800">{dept.avgMileage}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-[8px] text-green-500 uppercase font-bold mb-0.5">运营</div>
|
||||
@@ -1878,7 +1885,7 @@ export default function App() {
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, source: 'department' });
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, category: 'Operating', source: 'department' });
|
||||
}}
|
||||
className="text-[10px] font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded"
|
||||
>
|
||||
@@ -1890,42 +1897,42 @@ export default function App() {
|
||||
<div className="p-2 border-t border-gray-50 bg-gray-50/30 grid grid-cols-3 gap-1">
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">4.5T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t4_5}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">冷链</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t4_5c}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">18T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t18}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">49T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t49}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">挂车</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.trailer}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">其他</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.other}</div>
|
||||
@@ -1958,7 +1965,7 @@ export default function App() {
|
||||
className="text-[11px] font-bold text-blue-600 whitespace-nowrap"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, source: 'department' });
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, category: 'Operating', source: 'department' });
|
||||
}}
|
||||
>
|
||||
资产: {m.total}
|
||||
@@ -1981,42 +1988,42 @@ export default function App() {
|
||||
<div className="p-2 border-t border-gray-50 bg-gray-50/30 grid grid-cols-3 gap-1">
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">4.5T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t4_5}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">冷链</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t4_5c}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">18T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t18}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">49T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t49}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">挂车</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.trailer}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">其他</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.other}</div>
|
||||
@@ -2052,25 +2059,9 @@ export default function App() {
|
||||
>按城市</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-64 w-full">
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={(() => {
|
||||
if (regionChartView === 'region') {
|
||||
const regions: { [key: string]: number } = {};
|
||||
customerData.forEach(item => {
|
||||
regions[item.region] = (regions[item.region] || 0) + item.total;
|
||||
});
|
||||
return Object.entries(regions).map(([name, value]) => ({ name, value }));
|
||||
} else {
|
||||
const cities: { [key: string]: number } = {};
|
||||
customerData.forEach(item => {
|
||||
cities[item.city] = (cities[item.city] || 0) + item.total;
|
||||
});
|
||||
return Object.entries(cities)
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
})()}>
|
||||
<BarChart data={regionChartData} margin={{ top: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0" />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#94a3b8', fontSize: 12 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#94a3b8', fontSize: 12 }} />
|
||||
@@ -2212,90 +2203,55 @@ export default function App() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-xs">
|
||||
{uniqueRegions.filter(r => !regionFilters.region || r === regionFilters.region).map((region) => {
|
||||
const regionStats = customerData.filter(s => {
|
||||
const matchRegion = s.region === region;
|
||||
const matchCity = !regionFilters.city || s.city === regionFilters.city;
|
||||
const matchCustomer = !regionFilters.customer || s.customer.toLowerCase().includes(regionFilters.customer.toLowerCase());
|
||||
return matchRegion && matchCity && matchCustomer;
|
||||
});
|
||||
const totalAssets = regionStats.reduce((acc, s) => acc + s.total, 0);
|
||||
if (totalAssets === 0) return null;
|
||||
|
||||
const isExpanded = expandedRegions.has(region);
|
||||
|
||||
{regionData.filter(r => !regionFilters.region || r.region === regionFilters.region).map((r) => {
|
||||
const isExpanded = expandedRegions.has(r.region);
|
||||
return (
|
||||
<React.Fragment key={region}>
|
||||
<tr
|
||||
<React.Fragment key={r.region}>
|
||||
<tr
|
||||
className={`border-b border-slate-100 cursor-pointer transition-colors ${isExpanded ? 'bg-slate-50' : 'bg-white hover:bg-slate-50/50'}`}
|
||||
onClick={() => toggleRegion(region)}
|
||||
onClick={() => toggleRegion(r.region)}
|
||||
>
|
||||
<td className="p-2 font-bold text-slate-700 flex items-center gap-2">
|
||||
{isExpanded ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<Truck size={14} className="text-slate-400" />
|
||||
{region}区域
|
||||
</td>
|
||||
<td className="p-2 text-center font-bold text-slate-600">{totalAssets}</td>
|
||||
<td
|
||||
className="p-2 text-center text-green-600 font-bold cursor-pointer hover:bg-green-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset' });
|
||||
}}
|
||||
>
|
||||
{Math.floor(totalAssets * 0.8)}
|
||||
</td>
|
||||
<td
|
||||
className="p-2 text-center text-orange-600 font-bold cursor-pointer hover:bg-orange-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset' });
|
||||
}}
|
||||
>
|
||||
{Math.floor(totalAssets * 0.05)}
|
||||
</td>
|
||||
<td className="p-2 text-center text-slate-500 font-medium">
|
||||
{regionStats.slice(0, 2).map(s => s.customer).join(', ')}
|
||||
{r.region}区域
|
||||
</td>
|
||||
<td className="p-2 text-center font-bold text-slate-600">{r.totalAssets}</td>
|
||||
<td className="p-2 text-center text-green-600 font-bold">{r.operatingCount}</td>
|
||||
<td className="p-2 text-center text-orange-600 font-bold">{r.pendingCount || ''}</td>
|
||||
<td className="p-2 text-center text-slate-500 font-medium">{r.customers.slice(0, 2).join(', ')}</td>
|
||||
</tr>
|
||||
{isExpanded && ['4.5T', '18T', '49T'].map(type => {
|
||||
const typeTotal = regionStats.reduce((acc, s) => {
|
||||
if (type === '4.5T') return acc + s.t4_5 + s.t4_5c;
|
||||
if (type === '18T') return acc + s.t18;
|
||||
if (type === '49T') return acc + s.t49;
|
||||
return acc;
|
||||
}, 0);
|
||||
if (typeTotal === 0) return null;
|
||||
|
||||
{isExpanded && r.cities.map((city) => {
|
||||
const cityKey = `${r.region}-${city.city}`;
|
||||
const isCityExpanded = expandedRegionCities.has(cityKey);
|
||||
return (
|
||||
<React.Fragment key={type}>
|
||||
<tr className="border-b border-gray-50 hover:bg-gray-50">
|
||||
<td className="p-2 pl-8 text-gray-500 flex items-center gap-2">
|
||||
<div className="w-1 h-1 bg-slate-300 rounded-full"></div>
|
||||
{type} 车型
|
||||
</td>
|
||||
<td className="p-2 text-center text-gray-600">{typeTotal}</td>
|
||||
<td
|
||||
className="p-2 text-center text-green-600 cursor-pointer hover:bg-green-50"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', type, category: 'Operating', source: 'asset' })}
|
||||
>
|
||||
{Math.floor(typeTotal * 0.8)}
|
||||
</td>
|
||||
<td
|
||||
className="p-2 text-center text-orange-600 cursor-pointer hover:bg-orange-50"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', type, category: 'Pending', source: 'asset' })}
|
||||
>
|
||||
{Math.floor(typeTotal * 0.05)}
|
||||
</td>
|
||||
<td className="p-2 text-center text-gray-400 italic">
|
||||
{regionStats.filter(s => {
|
||||
if (type === '4.5T') return (s.t4_5 + s.t4_5c) > 0;
|
||||
if (type === '18T') return s.t18 > 0;
|
||||
if (type === '49T') return s.t49 > 0;
|
||||
return false;
|
||||
}).map(s => s.customer).join(', ')}
|
||||
<React.Fragment key={city.city}>
|
||||
<tr
|
||||
className="border-b border-gray-50 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => toggleRegionCity(cityKey)}
|
||||
>
|
||||
<td className="p-2 pl-8 text-slate-600 flex items-center gap-2">
|
||||
{isCityExpanded ? <ChevronDown size={12} className="text-slate-400" /> : <ChevronRight size={12} className="text-slate-400" />}
|
||||
<MapPin size={12} className="text-slate-300" />
|
||||
<span className="font-medium">{city.city}</span>
|
||||
</td>
|
||||
<td className="p-2 text-center text-slate-600 font-medium">{city.totalAssets}</td>
|
||||
<td className="p-2 text-center text-green-600">{city.operatingCount}</td>
|
||||
<td className="p-2 text-center text-orange-600">{city.pendingCount || ''}</td>
|
||||
<td className="p-2 text-center text-slate-400 text-[10px] italic">{city.customers.slice(0, 2).join(', ')}</td>
|
||||
</tr>
|
||||
{isCityExpanded && city.typeBreakdown.map(tb => (
|
||||
<tr key={tb.type} className="border-b border-gray-50/50 bg-slate-50/30">
|
||||
<td className="p-2 pl-14 text-gray-400 flex items-center gap-2">
|
||||
<div className="w-1 h-1 bg-slate-300 rounded-full"></div>
|
||||
{tb.type} 车型
|
||||
</td>
|
||||
<td className="p-2 text-center text-gray-500">{tb.total}</td>
|
||||
<td className="p-2 text-center text-green-500">{tb.operating}</td>
|
||||
<td className="p-2 text-center text-orange-500">{tb.inventory || ''}</td>
|
||||
<td className="p-2 text-center text-gray-400 text-[10px] italic">{tb.customers.slice(0, 2).join(', ')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
@@ -2645,7 +2601,7 @@ export default function App() {
|
||||
<tr className="bg-emerald-700 text-white text-[11px] uppercase tracking-wider border-b border-emerald-800">
|
||||
<th className="p-2 font-semibold border-r border-emerald-600 w-40">客户名称</th>
|
||||
<th className="p-2 font-semibold border-r border-emerald-600 w-24">所在区域</th>
|
||||
<th className="p-2 font-semibold border-r border-emerald-600 w-24">关联业务员</th>
|
||||
<th className="p-2 font-semibold border-r border-emerald-600 w-24">关联业务负责人</th>
|
||||
<th className="p-2 font-semibold border-r border-emerald-600 text-center w-20">4.5T</th>
|
||||
<th className="p-2 font-semibold border-r border-emerald-600 text-center w-20">4.5T冷链</th>
|
||||
<th className="p-2 font-semibold border-r border-emerald-600 text-center w-20">18T</th>
|
||||
@@ -2689,7 +2645,7 @@ export default function App() {
|
||||
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||
<div className="text-[10px] text-gray-400 uppercase mb-1">客户详情</div>
|
||||
<div className="text-sm font-bold text-gray-700">{cust.customer}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">主责业务员: {cust.manager}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">主责业务负责人: {cust.manager}</div>
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||
<div className="text-[10px] text-gray-400 uppercase mb-1">主要车型</div>
|
||||
|
||||
Reference in New Issue
Block a user