- classifyVehicleType now parses dic_type.dic_name (e.g. "4.5吨冷链车") instead of raw model code - Remove overly strict completionRate >= 0.8 filter for hopeless candidates - Use vehicle's yearTarget as fallback when inventory has no assessment target - Filter out suggestions with no candidates (not actionable) - estimatedGain counts rescue_hopeless suggestions as potential gains Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
25 KiB
Three Operations Modules Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add department, region, and customer operations statistics sections to the dashboard, ported from lnoneos prototype with real MySQL data.
Architecture: All 3 new API endpoints aggregate from the existing getVehicles() cache (no new DB queries). A new macro-region mapping function converts province/city to 华东/华南/etc. The frontend adds 3 new collapsible sections below the existing asset summary table, each with desktop table + mobile card views. The existing vehicle list modal is extended with new filter params (manager, customer, isColdChain, isTrailer).
Tech Stack: Hono (backend), React + Tailwind CSS + Motion (frontend), TypeScript throughout.
Reference: lnoneos prototype at /Users/kkfluous/Projects/ai-coding/lnoneos/src/App.tsx
Task 1: Backend — Macro-region mapping + vehicle type classification helpers
Files:
-
Modify:
src/server/routes/vehicles.ts(add functions after line ~111) -
Step 1: Add macro-region mapping function
Add after mapInventoryRegion (line 111) in src/server/routes/vehicles.ts:
// Macro-region mapping: province/city -> 华东/华南/华北/华中/西南/西北/其他
function mapMacroRegion(province: string | null, city: string | null): string {
const prov = (province || '').trim();
const c = (city || '').trim();
const loc = prov + c;
// 华东: 上海/江苏/浙江/安徽/福建/江西/山东
if (/上海|江苏|浙江|安徽|福建|江西|山东|南京|杭州|合肥|济南|青岛|苏州|宁波|厦门|嘉兴|无锡/.test(loc)) return '华东';
// 华南: 广东/广西/海南
if (/广东|广西|海南|广州|深圳|佛山|东莞|珠海|惠州|中山|南宁/.test(loc)) return '华南';
// 华北: 北京/天津/河北/山西/内蒙古
if (/北京|天津|河北|山西|内蒙古|石家庄|太原|呼和浩特/.test(loc)) return '华北';
// 华中: 河南/湖北/湖南
if (/河南|湖北|湖南|郑州|武汉|长沙/.test(loc)) return '华中';
// 西南: 重庆/四川/贵州/云南/西藏
if (/重庆|四川|贵州|云南|西藏|成都|昆明|贵阳/.test(loc)) return '西南';
// 西北: 陕西/甘肃/青海/宁夏/新疆
if (/陕西|甘肃|青海|宁夏|新疆|西安|兰州|乌鲁木齐/.test(loc)) return '西北';
return '其他';
}
// Vehicle type classification for per-type counts
type VehicleTypeCounts = { t4_5: number; t4_5c: number; t18: number; t49: number; trailer: number; other: number; total: number };
function classifyVehicleType(v: Vehicle): keyof Omit<VehicleTypeCounts, 'total'> {
if (v.type === '4.5T' && !v.model.includes('冷链')) return 't4_5';
if (v.type === '4.5T' && v.model.includes('冷链')) return 't4_5c';
if (v.type === '18T') return 't18';
if (v.type === '49T') return 't49';
if (v.type === '挂车' || v.model.includes('挂车')) return 'trailer';
return 'other';
}
function countByType(vehicles: Vehicle[]): VehicleTypeCounts {
const counts: VehicleTypeCounts = { t4_5: 0, t4_5c: 0, t18: 0, t49: 0, trailer: 0, other: 0, total: 0 };
for (const v of vehicles) {
counts[classifyVehicleType(v)]++;
counts.total++;
}
return counts;
}
- Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: no errors
- Step 3: Commit
git add src/server/routes/vehicles.ts
git commit -m "feat: add macro-region mapping and vehicle type classification helpers"
Task 2: Backend — Three new API endpoints
Files:
-
Modify:
src/server/routes/vehicles.ts(add endpoints before the/listendpoint) -
Step 1: Add
/dept-statsendpoint
Add before the VEHICLE_TYPE_FILTERS const (which is before /list) in src/server/routes/vehicles.ts:
// GET /api/vehicles/dept-stats
app.get('/dept-stats', async (c) => {
const vehicles = await getVehicles();
// Only count operating vehicles for department stats (those with a customerManager)
const withManager = vehicles.filter((v) => v.customerManager);
const deptMap = new Map<string, Map<string, Vehicle[]>>();
for (const v of withManager) {
const dept = v.departmentName || '未分配';
const mgr = v.customerManager!;
if (!deptMap.has(dept)) deptMap.set(dept, new Map());
const mgrMap = deptMap.get(dept)!;
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
mgrMap.get(mgr)!.push(v);
}
const result = Array.from(deptMap.entries()).map(([department, mgrMap]) => {
const allDeptVehicles = Array.from(mgrMap.values()).flat();
const managers = Array.from(mgrMap.entries())
.map(([manager, mvs]) => ({
manager,
department,
...countByType(mvs),
}))
.sort((a, b) => b.total - a.total);
return {
department,
totalAssets: allDeptVehicles.length,
operatingCount: allDeptVehicles.filter((v) => v.status === 'Operating').length,
idleCount: allDeptVehicles.filter((v) => v.status !== 'Operating').length,
managers,
};
}).sort((a, b) => b.totalAssets - a.totalAssets);
return c.json(result);
});
- Step 2: Add
/region-statsendpoint
// GET /api/vehicles/region-stats
app.get('/region-stats', async (c) => {
const vehicles = await getVehicles();
const operating = vehicles.filter((v) => v.status === 'Operating');
const regionMap = new Map<string, Vehicle[]>();
for (const v of operating) {
const region = mapMacroRegion(v.province, v.city);
if (!regionMap.has(region)) regionMap.set(region, []);
regionMap.get(region)!.push(v);
}
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
const result = regionOrder
.filter((r) => regionMap.has(r))
.map((region) => {
const rv = regionMap.get(region)!;
const customers = Array.from(new Set(rv.map((v) => v.customerName).filter(Boolean))) as string[];
const typeBreakdown = ['4.5T', '18T', '49T'].map((type) => {
const typeVehicles = rv.filter((v) => v.type === type);
return {
type,
total: typeVehicles.length,
operating: typeVehicles.filter((v) => v.status === 'Operating').length,
inventory: typeVehicles.filter((v) => v.status === 'Inventory').length,
customers: Array.from(new Set(typeVehicles.map((v) => v.customerName).filter(Boolean))) as string[],
};
}).filter((t) => t.total > 0);
return {
region,
totalAssets: rv.length,
operatingCount: rv.filter((v) => v.status === 'Operating').length,
inventoryCount: rv.filter((v) => v.status === 'Inventory').length,
customers,
typeBreakdown,
};
});
return c.json(result);
});
- Step 3: Add
/customer-statsendpoint
// GET /api/vehicles/customer-stats
app.get('/customer-stats', async (c) => {
const vehicles = await getVehicles();
const operating = vehicles.filter((v) => v.status === 'Operating' && v.customerName);
const custMap = new Map<string, Vehicle[]>();
for (const v of operating) {
const cust = v.customerName!;
if (!custMap.has(cust)) custMap.set(cust, []);
custMap.get(cust)!.push(v);
}
const result = Array.from(custMap.entries())
.map(([customer, cvs]) => {
const first = cvs[0];
return {
customer,
manager: first.customerManager || '',
brand: first.brandLabel || '',
department: first.departmentName || '',
region: mapMacroRegion(first.province, first.city),
city: first.city || '',
...countByType(cvs),
};
})
.sort((a, b) => b.total - a.total);
return c.json(result);
});
- Step 4: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: no errors
- Step 5: Commit
git add src/server/routes/vehicles.ts
git commit -m "feat: add dept-stats, region-stats, customer-stats API endpoints"
Task 3: Backend — Extend /list with new filter params
Files:
-
Modify:
src/server/routes/vehicles.ts(the/listendpoint) -
Step 1: Add manager, customer, isColdChain, isTrailer filters
In the /list endpoint, after the existing category filter block, add:
const { batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer } = c.req.query();
(Replace the existing destructure line.)
Then after the if (category) block, add:
if (manager) {
filtered = filtered.filter((v) => v.customerManager === manager);
}
if (customer) {
filtered = filtered.filter((v) => v.customerName === customer);
}
if (isColdChain !== undefined) {
const wantCold = isColdChain === 'true';
filtered = filtered.filter((v) => wantCold ? v.model.includes('冷链') : !v.model.includes('冷链'));
}
if (isTrailer !== undefined) {
const wantTrailer = isTrailer === 'true';
filtered = filtered.filter((v) => wantTrailer ? (v.type === '挂车' || v.model.includes('挂车')) : !(v.type === '挂车' || v.model.includes('挂车')));
}
- Step 2: Verify TypeScript compiles and build passes
Run: npx tsc --noEmit && npx vite build
Expected: no errors, successful build
- Step 3: Commit
git add src/server/routes/vehicles.ts
git commit -m "feat: extend /list endpoint with manager, customer, coldchain, trailer filters"
Task 4: Frontend — Types and API client
Files:
-
Modify:
src/types.ts -
Modify:
src/api.ts -
Step 1: Add new interfaces to
src/types.ts
Append at end of file:
export interface ManagerStats {
manager: string;
department: string;
t4_5: number;
t4_5c: number;
t18: number;
t49: number;
trailer: number;
other: number;
total: number;
}
export interface DeptGroup {
department: string;
totalAssets: number;
operatingCount: number;
idleCount: number;
managers: ManagerStats[];
}
export interface RegionGroup {
region: string;
totalAssets: number;
operatingCount: number;
inventoryCount: number;
customers: string[];
typeBreakdown: { type: string; total: number; operating: number; inventory: number; customers: string[] }[];
}
export interface CustomerStats {
customer: string;
manager: string;
brand: string;
department: string;
region: string;
city: string;
t4_5: number;
t4_5c: number;
t18: number;
t49: number;
trailer: number;
other: number;
total: number;
}
- Step 2: Add API functions to
src/api.ts
Add imports at top:
import type {
SummaryData,
TypeSummary,
VehicleListItem,
DeptGroup,
RegionGroup,
CustomerStats,
} from './types';
Add after fetchVehicleList:
export async function fetchDeptStats(): Promise<DeptGroup[]> {
return fetchJson<DeptGroup[]>(`${BASE}/dept-stats`);
}
export async function fetchRegionStats(): Promise<RegionGroup[]> {
return fetchJson<RegionGroup[]>(`${BASE}/region-stats`);
}
export async function fetchCustomerStats(): Promise<CustomerStats[]> {
return fetchJson<CustomerStats[]>(`${BASE}/customer-stats`);
}
Also update fetchVehicleList params type to include new filters:
export async function fetchVehicleList(params: {
batch?: string;
model?: string;
location?: string;
status?: string;
category?: string;
vehicleType?: string;
manager?: string;
customer?: string;
isColdChain?: string;
isTrailer?: string;
}): Promise<VehicleListItem[]> {
const query = new URLSearchParams();
if (params.batch) query.set('batch', params.batch);
if (params.model) query.set('model', params.model);
if (params.location) query.set('location', params.location);
if (params.status) query.set('status', params.status);
if (params.category) query.set('category', params.category);
if (params.vehicleType) query.set('vehicleType', params.vehicleType);
if (params.manager) query.set('manager', params.manager);
if (params.customer) query.set('customer', params.customer);
if (params.isColdChain) query.set('isColdChain', params.isColdChain);
if (params.isTrailer) query.set('isTrailer', params.isTrailer);
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
}
- Step 3: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: no errors
- Step 4: Commit
git add src/types.ts src/api.ts
git commit -m "feat: add frontend types and API client for dept/region/customer stats"
Task 5: Frontend — Extend App.tsx state, data loading, imports, and showPlateNumbers
Files:
-
Modify:
src/App.tsx -
Step 1: Update imports
Replace the existing import lines at top of src/App.tsx:
import {
Truck,
Warehouse,
Activity,
PlusCircle,
MinusCircle,
History,
ChevronDown,
ChevronRight,
Info,
Loader2,
Search,
Filter,
ArrowRightLeft,
} from 'lucide-react';
Update type imports:
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats } from './types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats } from './api';
- Step 2: Add new state variables
After the existing state declarations (after const [modalLoading, setModalLoading] = useState(false);), add:
// Dept/Region/Customer data
const [deptData, setDeptData] = useState<DeptGroup[]>([]);
const [regionData, setRegionData] = useState<RegionGroup[]>([]);
const [customerData, setCustomerData] = useState<CustomerStats[]>([]);
// Dept section state
const [deptViewMode, setDeptViewMode] = useState<'department' | 'manager'>('department');
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
const [expandedManagerDetails, setExpandedManagerDetails] = useState<Set<string>>(new Set());
const [selectedManager, setSelectedManager] = useState<string>('All');
// Region section state
const [expandedRegions, setExpandedRegions] = useState<Set<string>>(new Set());
const [regionFilters, setRegionFilters] = useState({ region: '', city: '', customer: '' });
const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false);
// Customer section state
const [expandedCustomers, setExpandedCustomers] = useState<Set<string>>(new Set());
const [customerFilters, setCustomerFilters] = useState({ customer: '', brand: '', department: '', manager: '', region: '' });
const [isCustomerFilterOpen, setIsCustomerFilterOpen] = useState(false);
- Step 3: Update loadData to fetch all 3 new endpoints
Update the loadData callback:
const loadData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [s, byType, dept, region, cust] = await Promise.all([
fetchSummary(),
fetchByType(),
fetchDeptStats(),
fetchRegionStats(),
fetchCustomerStats(),
]);
setSummary(s);
setProcessedData(byType);
setDeptData(dept);
setRegionData(region);
setCustomerData(cust);
setLastUpdate(new Date().toLocaleString('zh-CN'));
} catch (e) {
setError(e instanceof Error ? e.message : '数据加载失败');
} finally {
setLoading(false);
}
}, []);
- Step 4: Extend showPlateNumbers type
Update the showPlateNumbers state type:
const [showPlateNumbers, setShowPlateNumbers] = useState<{
batch: string;
model: string;
location: string;
category?: 'Inventory' | 'Pending' | 'Delivered' | 'Returned' | 'Replaced' | 'Operating';
vehicleType?: string;
manager?: string;
customer?: string;
isColdChain?: boolean;
isTrailer?: boolean;
} | null>(null);
- Step 5: Update modal loading to pass new filter params
In the useEffect for modal loading, update the params block (the "Normal vehicle list" section):
// Normal vehicle list
setModalWeeklyDetail([]);
const params: Record<string, string> = {};
if (showPlateNumbers.vehicleType) params.vehicleType = showPlateNumbers.vehicleType;
if (showPlateNumbers.batch !== 'All') params.batch = showPlateNumbers.batch;
if (showPlateNumbers.model !== 'All') params.model = showPlateNumbers.model;
if (showPlateNumbers.location !== 'All') params.location = showPlateNumbers.location;
if (cat === 'Inventory') params.status = 'Inventory';
if (cat === 'Operating') params.category = 'Operating';
if (showPlateNumbers.manager) params.manager = showPlateNumbers.manager;
if (showPlateNumbers.customer) params.customer = showPlateNumbers.customer;
if (showPlateNumbers.isColdChain !== undefined) params.isColdChain = String(showPlateNumbers.isColdChain);
if (showPlateNumbers.isTrailer !== undefined) params.isTrailer = String(showPlateNumbers.isTrailer);
- Step 6: Add toggle helpers and derived data
After the existing toggleModel function, add:
const toggleDept = (dept: string) => {
const newSet = new Set(expandedDepts);
if (newSet.has(dept)) newSet.delete(dept);
else newSet.add(dept);
setExpandedDepts(newSet);
};
const toggleManagerDetails = (manager: string) => {
const newSet = new Set(expandedManagerDetails);
if (newSet.has(manager)) newSet.delete(manager);
else newSet.add(manager);
setExpandedManagerDetails(newSet);
};
const toggleRegion = (region: string) => {
const newSet = new Set(expandedRegions);
if (newSet.has(region)) newSet.delete(region);
else newSet.add(region);
setExpandedRegions(newSet);
};
const toggleCustomer = (customer: string) => {
const newSet = new Set(expandedCustomers);
if (newSet.has(customer)) newSet.delete(customer);
else newSet.add(customer);
setExpandedCustomers(newSet);
};
// 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
.flatMap((d) => d.managers)
.filter((m) => selectedManager === 'All' || m.manager === selectedManager)
.sort((a, b) => b.total - a.total);
// Derived data for customer section
const filteredCustomerStats = customerData.filter((s) => {
const mc = !customerFilters.customer || s.customer.toLowerCase().includes(customerFilters.customer.toLowerCase());
const mb = !customerFilters.brand || s.brand === customerFilters.brand;
const md = !customerFilters.department || s.department === customerFilters.department;
const mm = !customerFilters.manager || s.manager.toLowerCase().includes(customerFilters.manager.toLowerCase());
const mr = !customerFilters.region || s.region === customerFilters.region;
return mc && mb && md && mm && mr;
});
const uniqueBrands = Array.from(new Set(customerData.map((s) => s.brand).filter(Boolean)));
const uniqueDepts = Array.from(new Set(customerData.map((s) => s.department).filter(Boolean)));
const uniqueRegions = Array.from(new Set(customerData.map((s) => s.region)));
const uniqueCities = Array.from(new Set(customerData.map((s) => s.city).filter(Boolean)));
// Derived data for region section
const filteredRegionData = regionData.filter((r) => !regionFilters.region || r.region === regionFilters.region);
- Step 7: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: no errors
- Step 8: Commit
git add src/App.tsx
git commit -m "feat: add state, data loading, and helpers for 3 new modules"
Task 6: Frontend — Department Operations UI
Files:
-
Modify:
src/App.tsx(add section after the asset summary table's closing</div>, before the Plate Number Modal) -
Step 1: Add the department operations section
Insert the department operations section JSX. Reference: lnoneos lines 1362-1880. This section goes right after the closing </div> of the asset summary table (bg-white rounded-sm border...) and before {/* Plate Number Modal */}.
The section includes:
- Header with title "部门运营统计"
- Dark summary bar (总资产/运营中/闲置中 — skip 平均出勤)
- Toggle buttons (按部门 / 按业务员) + manager filter dropdown
- Desktop table view (
hidden lg:block)- Department mode: department rows expandable to show manager cards with 6 vehicle type cells
- Manager mode: flat manager rows expandable to show 6 vehicle type cells
- Mobile card view (
lg:hidden)
Port the JSX from lnoneos lines 1362-1880, replacing:
MOCK_DEPT_STATS→deptDataDEPT_TOTALS.total→deptData.reduce((s, d) => s + d.totalAssets, 0)allManagersList/managerStats/deptViewMode/expandedDepts/expandedManagerDetails/selectedManager→ already defined in Task 5setShowPlateNumberscalls: keep the same structure but removesourcefield (not needed in ln-bi)- Remove
ArrowRightLefticon usage in toggle buttons — replace with simple text button - All
rounded-2xl→rounded-smto match ln-bi style - All
shadow-smstay as is
The setShowPlateNumbers calls from lnoneos use manager, type, isColdChain, isTrailer fields which we added to the state type in Task 5.
- Step 2: Verify TypeScript compiles and build passes
Run: npx tsc --noEmit && npx vite build
Expected: no errors, successful build
- Step 3: Commit
git add src/App.tsx
git commit -m "feat: add department operations statistics section"
Task 7: Frontend — Region Operations UI
Files:
-
Modify:
src/App.tsx(add section after department section, before Plate Number Modal) -
Step 1: Add the region operations section
Reference: lnoneos lines 1882-2174. Insert after the department section.
The section includes:
- Slate-themed header with "区域运营统计" + filter button
- Filter popover (客户搜索 / 区域下拉 / 城市下拉)
- Desktop table: expandable region rows → vehicle type sub-rows
- Mobile cards: expandable region cards with type breakdown
Port the JSX from lnoneos, replacing:
MOCK_CUSTOMER_STATSregion-based filtering → usefilteredRegionData(from Task 5)- Region stats aggregation in lnoneos used mock data with
Math.floor(totalAssets * 0.8)for operating — use realr.operatingCountandr.inventoryCount - Type breakdown: use
r.typeBreakdownarray from API uniqueRegions/uniqueCities→ already defined in Task 5setShowPlateNumberscalls: usevehicleTypefield for type filtering instead of lnoneos'stypefieldrounded-2xl→rounded-sm- Filter popover for cities: derive from
regionData(all unique cities from customers)
Note: The region filter's city dropdown needs city data. Add to Task 5's derived data if not already there. The regionData from API contains customer names but not cities. For the city filter, we can derive from customerData filtered by region.
- Step 2: Verify TypeScript compiles and build passes
Run: npx tsc --noEmit && npx vite build
Expected: no errors, successful build
- Step 3: Commit
git add src/App.tsx
git commit -m "feat: add region operations statistics section"
Task 8: Frontend — Customer Operations UI
Files:
-
Modify:
src/App.tsx(add section after region section, before Plate Number Modal) -
Step 1: Add the customer operations section
Reference: lnoneos lines 2176-2496. Insert after the region section.
The section includes:
- Emerald-themed header with "客户运营统计" + filter button
- Filter popover (客户名搜索 / 业务员搜索 / 品牌下拉 / 部门下拉 / 区域下拉)
- Desktop table: customer rows with 6 vehicle type columns + total, expandable detail cards
- Mobile cards: customer cards with expandable vehicle type grid
Port the JSX from lnoneos, replacing:
-
MOCK_CUSTOMER_STATS→filteredCustomerStats(from Task 5) -
DEPT_TOTALS.total→customerData.reduce((s, c) => s + c.total, 0)for asset ratio -
setShowPlateNumberscalls: usevehicleType+customerfields -
uniqueBrands/uniqueDepts/uniqueRegions→ already defined in Task 5 -
rounded-2xl→rounded-sm -
Step 2: Verify TypeScript compiles and build passes
Run: npx tsc --noEmit && npx vite build
Expected: no errors, successful build
- Step 3: Commit
git add src/App.tsx
git commit -m "feat: add customer operations statistics section"
Task 9: Final verification and build
Files: All modified files
- Step 1: Full TypeScript check
Run: npx tsc --noEmit
Expected: no errors
- Step 2: Production build
Run: npx vite build
Expected: successful build with no warnings
- Step 3: Verify all sections render
Run: npm run dev and check:
-
Department section loads with real data
-
Region section loads with real data
-
Customer section loads with real data
-
Filter popovers work
-
Expand/collapse works
-
Click-through to plate number modal works
-
Step 4: Final commit if any fixes needed
git add -A
git commit -m "fix: address any issues from final review"