Files
ln-bi/docs/superpowers/plans/2026-04-16-smart-scheduling.md
kkfluous 32b297c731 docs: 智能调度模块实现计划
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:17:08 +08:00

1616 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Smart Scheduling (智能调度) 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:** Build a smart scheduling module that analyzes vehicle mileage assessment data, generates vehicle replacement suggestions using a greedy priority-matching algorithm, and presents an actionable UI for dispatchers.
**Architecture:** Backend Hono routes compute suggestions in real-time by aggregating assessment targets, vehicle mileage, customer daily averages, inventory vehicles, and GPS location data. Frontend React module displays suggestions with batch filtering, priority-sorted list, and a detail modal with before/after comparison. A notify endpoint records dispatcher actions and triggers external callbacks.
**Tech Stack:** Hono (backend), MySQL (mysql2/promise), React 19, Tailwind CSS, motion/react, lucide-react, recharts (optional for future charts)
**Spec:** `docs/superpowers/specs/2026-04-16-smart-scheduling-design.md`
---
## File Structure
### Backend (new files)
| File | Responsibility |
|------|---------------|
| `src/server/routes/scheduling/index.ts` | Router entry — mounts suggestions + notify sub-routes |
| `src/server/routes/scheduling/suggestions.ts` | `GET /` — queries all data sources, runs classification + matching algorithm, returns `SchedulingResponse` |
| `src/server/routes/scheduling/notify.ts` | `POST /notify` — records action, triggers callback, returns success |
| `src/server/routes/scheduling/types.ts` | All scheduling-specific TypeScript interfaces |
| `src/server/routes/scheduling/algorithm.ts` | Pure functions: classifyVehicles, generateSuggestions, vehicle-type matching, region matching |
### Backend (modified files)
| File | Change |
|------|--------|
| `src/server/index.ts` | Add `import schedulingRouter` and `app.route('/api/scheduling', schedulingRouter)` |
| `src/server/routes/vehicles.ts` | Export `mapRegion()` function (currently module-private) |
### Frontend (new files)
| File | Responsibility |
|------|---------------|
| `src/modules/scheduling/types.ts` | Frontend type definitions mirroring backend response |
| `src/modules/scheduling/api.ts` | `fetchSuggestions()`, `sendNotify()` client functions |
| `src/modules/scheduling/SchedulingModule.tsx` | Main entry — state management, data loading, batch filter, stats cards, list + detail |
| `src/modules/scheduling/SuggestionList.tsx` | Renders priority-sorted suggestion cards |
| `src/modules/scheduling/SuggestionDetail.tsx` | Modal with current vehicle info, candidate list with before/after comparison, notify button |
### Frontend (modified files)
| File | Change |
|------|--------|
| `src/App.tsx` | Add scheduling module to `MODULES` array |
---
## Task 1: Backend Types
**Files:**
- Create: `src/server/routes/scheduling/types.ts`
- [ ] **Step 1: Create the scheduling types file**
```typescript
// src/server/routes/scheduling/types.ts
export interface SchedulingVehicleInfo {
plateNumber: string;
targetId: number;
targetName: string;
vehicleType: string; // "4.5T冷链" | "4.5T普货" | "18T" | "49T" | "挂车"
totalMileage: number;
completionRate: number; // 0-1
yearTarget: number;
region: string; // mapped region: "嘉兴" | "广东" | "北京" | "新疆" | "其他"
province: string;
customer: string | null;
customerAvgDaily: number;
predictedYearEnd: number;
daysLeft: number;
}
export interface CandidateVehicle {
plateNumber: string;
targetId: number | null;
targetName: string | null;
vehicleType: string;
totalMileage: number;
completionRate: number;
yearTarget: number | null;
region: string;
province: string;
mileageGap: number;
predictedAfterSwap: number;
canQualifyAfterSwap: boolean;
}
export interface SchedulingSuggestion {
id: string;
priority: 'high' | 'medium';
type: 'replace_qualified' | 'rescue_hopeless';
currentVehicle: SchedulingVehicleInfo;
candidates: CandidateVehicle[];
reason: string;
}
export interface SchedulingSummary {
qualifiedCount: number;
hopelessCount: number;
suggestionCount: number;
estimatedGain: number;
}
export interface SchedulingTargetOption {
id: number;
name: string;
vehicleCount: number;
}
export interface SchedulingResponse {
summary: SchedulingSummary;
suggestions: SchedulingSuggestion[];
targets: SchedulingTargetOption[];
}
export interface NotifyRequest {
suggestionId: string;
currentPlate: string;
candidatePlate: string;
}
/** Classification of a vehicle's qualification likelihood */
export type VehicleClassification = 'qualified' | 'hopeless' | 'normal';
/** Internal enriched vehicle used during algorithm computation */
export interface EnrichedVehicle {
plateNumber: string;
targetId: number;
targetName: string;
vehicleType: string;
totalMileage: number;
currentYearMileage: number;
completionRate: number;
yearTarget: number;
isQualified: boolean;
currentYearIsQualified: boolean;
dailyRequiredMileage: number;
region: string;
province: string;
customer: string | null;
customerAvgDaily: number;
predictedYearEnd: number;
daysLeft: number;
classification: VehicleClassification;
}
/** Inventory vehicle available for replacement */
export interface InventoryVehicle {
plateNumber: string;
vehicleType: string;
region: string;
province: string;
totalMileage: number;
/** If this inventory vehicle is also in an assessment target */
targetId: number | null;
targetName: string | null;
yearTarget: number | null;
completionRate: number;
}
```
- [ ] **Step 2: Commit**
```bash
git add src/server/routes/scheduling/types.ts
git commit -m "feat(scheduling): add backend type definitions"
```
---
## Task 2: Algorithm Pure Functions
**Files:**
- Create: `src/server/routes/scheduling/algorithm.ts`
- Modify: `src/server/routes/vehicles.ts` (export `mapRegion`)
- [ ] **Step 1: Export mapRegion from vehicles.ts**
In `src/server/routes/vehicles.ts`, the `mapRegion` function (line ~94) is currently not exported. Add the `export` keyword:
```typescript
// Change:
function mapRegion(province: string | null, city: string | null): string {
// To:
export function mapRegion(province: string | null, city: string | null): string {
```
- [ ] **Step 2: Create algorithm.ts with vehicle type matching**
```typescript
// src/server/routes/scheduling/algorithm.ts
import type {
EnrichedVehicle, InventoryVehicle, SchedulingSuggestion,
CandidateVehicle, VehicleClassification, SchedulingSummary,
} from './types.js';
// --- Vehicle type compatibility ---
const COLD_CHAIN_TYPES = new Set(['4.5T冷链']);
const COMPATIBLE_FOR_COLD_CHAIN = new Set(['4.5T冷链', '4.5T普货']);
/**
* Check if candidateType can replace sourceType.
* Rules:
* - 4.5T冷链 can go to 4.5T冷链 or 4.5T普货 (cold chain can run without AC)
* - 4.5T普货 can only go to 4.5T普货 (cannot reverse into cold chain)
* - All other types: exact match only (18T↔18T, 49T↔49T, 挂车↔挂车)
*/
export function isTypeCompatible(sourceType: string, candidateType: string): boolean {
if (sourceType === candidateType) return true;
// Cold chain vehicle can replace plain cargo
if (COLD_CHAIN_TYPES.has(candidateType) && COMPATIBLE_FOR_COLD_CHAIN.has(sourceType)) return true;
return false;
}
// --- Vehicle classification ---
const QUALIFIED_THRESHOLD = 1.2; // 120%
const HOPELESS_THRESHOLD = 0.6; // 60%
export function classifyVehicle(
currentYearIsQualified: boolean,
predictedYearEnd: number,
yearTarget: number,
): VehicleClassification {
if (currentYearIsQualified || (yearTarget > 0 && predictedYearEnd / yearTarget >= QUALIFIED_THRESHOLD)) {
return 'qualified';
}
if (yearTarget > 0 && predictedYearEnd / yearTarget < HOPELESS_THRESHOLD) {
return 'hopeless';
}
return 'normal';
}
// --- Suggestion generation ---
const MAX_CANDIDATES = 5;
export function generateSuggestions(
vehicles: EnrichedVehicle[],
inventoryVehicles: InventoryVehicle[],
): { suggestions: SchedulingSuggestion[]; summary: SchedulingSummary } {
const qualified = vehicles.filter(v => v.classification === 'qualified');
const hopeless = vehicles.filter(v => v.classification === 'hopeless');
const suggestions: SchedulingSuggestion[] = [];
// Track which inventory vehicles have been used as candidates (for estimatedGain)
const usedInventory = new Set<string>();
// --- Scenario B first (higher priority): rescue hopeless vehicles ---
for (const vehicle of hopeless) {
const candidates = findCandidatesForHopeless(vehicle, inventoryVehicles);
if (candidates.length === 0) continue;
suggestions.push({
id: `s-${vehicle.plateNumber}-${Date.now()}`,
priority: 'high',
type: 'rescue_hopeless',
currentVehicle: toVehicleInfo(vehicle),
candidates,
reason: `${vehicle.customer || '该客户'}日均里程仅 ${Math.round(vehicle.customerAvgDaily)} KM`
+ `该车达标概率 ${Math.round((vehicle.predictedYearEnd / vehicle.yearTarget) * 100)}%`
+ `建议替换为已达标车辆,将此车调配给高里程客户。`,
});
for (const c of candidates) usedInventory.add(c.plateNumber);
}
// --- Scenario A: replace qualified vehicles at high-mileage customers ---
for (const vehicle of qualified) {
// Only suggest if customer avg daily is above the vehicle's required daily
if (vehicle.customerAvgDaily <= vehicle.dailyRequiredMileage) continue;
const candidates = findCandidatesForQualified(vehicle, inventoryVehicles);
if (candidates.length === 0) continue;
suggestions.push({
id: `s-${vehicle.plateNumber}-${Date.now()}`,
priority: 'medium',
type: 'replace_qualified',
currentVehicle: toVehicleInfo(vehicle),
candidates,
reason: `${vehicle.customer || '该客户'}日均里程 ${Math.round(vehicle.customerAvgDaily)} KM高里程`
+ `该车已达标(完成率 ${Math.round(vehicle.completionRate * 100)}%`
+ `建议换上里程缺口大的车辆以加速达标。`,
});
for (const c of candidates) usedInventory.add(c.plateNumber);
}
// Sort: high priority first, then by predicted gap descending
suggestions.sort((a, b) => {
if (a.priority !== b.priority) return a.priority === 'high' ? -1 : 1;
return 0;
});
// Estimate gain: count candidates that canQualifyAfterSwap
let estimatedGain = 0;
for (const s of suggestions) {
if (s.candidates.some(c => c.canQualifyAfterSwap)) estimatedGain++;
}
return {
suggestions,
summary: {
qualifiedCount: qualified.length,
hopelessCount: hopeless.length,
suggestionCount: suggestions.length,
estimatedGain,
},
};
}
// --- Candidate finding ---
function findCandidatesForQualified(
vehicle: EnrichedVehicle,
inventory: InventoryVehicle[],
): CandidateVehicle[] {
return inventory
.filter(iv =>
isTypeCompatible(vehicle.vehicleType, iv.vehicleType) &&
iv.region === vehicle.region
)
.map(iv => {
const predictedAfterSwap = iv.totalMileage + vehicle.customerAvgDaily * vehicle.daysLeft;
const gap = (iv.yearTarget ?? vehicle.yearTarget) - iv.totalMileage;
return {
plateNumber: iv.plateNumber,
targetId: iv.targetId,
targetName: iv.targetName,
vehicleType: iv.vehicleType,
totalMileage: iv.totalMileage,
completionRate: iv.completionRate,
yearTarget: iv.yearTarget,
region: iv.region,
province: iv.province,
mileageGap: Math.max(0, gap),
predictedAfterSwap: Math.round(predictedAfterSwap),
canQualifyAfterSwap: predictedAfterSwap >= (iv.yearTarget ?? vehicle.yearTarget),
};
})
// Prioritize: largest gap that can still qualify after swap
.sort((a, b) => {
// canQualify first
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap) return a.canQualifyAfterSwap ? -1 : 1;
// Then largest gap
return b.mileageGap - a.mileageGap;
})
.slice(0, MAX_CANDIDATES);
}
function findCandidatesForHopeless(
vehicle: EnrichedVehicle,
inventory: InventoryVehicle[],
): CandidateVehicle[] {
return inventory
.filter(iv =>
isTypeCompatible(vehicle.vehicleType, iv.vehicleType) &&
iv.region === vehicle.region &&
// For hopeless: prefer already-qualified or high-mileage inventory vehicles
iv.completionRate >= 0.8
)
.map(iv => {
// This vehicle goes to the low-mileage customer, so predict with customer's avg
const predictedAfterSwap = iv.totalMileage + vehicle.customerAvgDaily * vehicle.daysLeft;
const gap = (iv.yearTarget ?? vehicle.yearTarget) - iv.totalMileage;
return {
plateNumber: iv.plateNumber,
targetId: iv.targetId,
targetName: iv.targetName,
vehicleType: iv.vehicleType,
totalMileage: iv.totalMileage,
completionRate: iv.completionRate,
yearTarget: iv.yearTarget,
region: iv.region,
province: iv.province,
mileageGap: Math.max(0, gap),
predictedAfterSwap: Math.round(predictedAfterSwap),
canQualifyAfterSwap: iv.completionRate >= 1.0, // already qualified stays qualified
};
})
// Prioritize: already qualified first, then highest completion rate
.sort((a, b) => {
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap) return a.canQualifyAfterSwap ? -1 : 1;
return b.completionRate - a.completionRate;
})
.slice(0, MAX_CANDIDATES);
}
function toVehicleInfo(v: EnrichedVehicle) {
return {
plateNumber: v.plateNumber,
targetId: v.targetId,
targetName: v.targetName,
vehicleType: v.vehicleType,
totalMileage: v.totalMileage,
completionRate: v.completionRate,
yearTarget: v.yearTarget,
region: v.region,
province: v.province,
customer: v.customer,
customerAvgDaily: v.customerAvgDaily,
predictedYearEnd: v.predictedYearEnd,
daysLeft: v.daysLeft,
};
}
```
- [ ] **Step 3: Verify TypeScript compiles**
```bash
cd /Users/kkfluous/Projects/ai-coding/ln-bi && npx tsc --noEmit src/server/routes/scheduling/algorithm.ts src/server/routes/scheduling/types.ts 2>&1 | head -20
```
- [ ] **Step 4: Commit**
```bash
git add src/server/routes/scheduling/algorithm.ts src/server/routes/vehicles.ts
git commit -m "feat(scheduling): add algorithm pure functions and export mapRegion"
```
---
## Task 3: Backend Suggestions Route
**Files:**
- Create: `src/server/routes/scheduling/suggestions.ts`
This is the core route handler. It queries 6 data sources, enriches vehicles, then calls the algorithm.
- [ ] **Step 1: Create suggestions.ts**
```typescript
// src/server/routes/scheduling/suggestions.ts
import { Hono } from 'hono';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
import { mapRegion } from '../vehicles.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import { classifyVehicle, generateSuggestions } from './algorithm.js';
import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse } from './types.js';
import type { AuthUser } from '../../auth/types.js';
const app = new Hono();
/**
* Classify vehicle type from model string (matches vehicles.ts logic).
* Returns a display label for the scheduling module.
*/
function classifyVehicleType(type: string, model: string): string {
if (type === '4.5T' && model.includes('冷链')) return '4.5T冷链';
if (type === '4.5T') return '4.5T普货';
if (type === '18T') return '18T';
if (type === '49T') return '49T';
if (type === '挂车' || model.includes('挂车')) return '挂车';
return type || '其他';
}
app.get('/', async (c) => {
const targetIdFilter = c.req.query('targetId') ? Number(c.req.query('targetId')) : null;
try {
// 1. Fetch assessment targets
const [targets] = await pool.execute(
'SELECT id, target_name, annual_mileage_per_vehicle FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
) as [any[], unknown];
const targetMap = new Map<number, { name: string; annualTarget: number }>();
for (const t of targets) {
targetMap.set(t.id, { name: t.target_name, annualTarget: Number(t.annual_mileage_per_vehicle) || 0 });
}
// 2. Fetch all assessment vehicles
const [assessmentRows] = await pool.execute(`
SELECT target_id, plate_number, today_mileage, vehicle_total_mileage,
current_mileage, current_year_mileage, current_year_mileage_task,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage, current_year_assessment_end_date
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
`) as [any[], unknown];
// 3. Fetch vehicle info (customer, department, manager, rent_status, type/model)
const infoMap = await fetchVehicleInfoMap();
// 4. Fetch vehicle type info from tab_truck
const [truckRows] = await pool.execute(`
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
FROM tab_truck truck
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
WHERE truck.is_deleted = 0 AND truck.is_operation = 1
`) as [any[], unknown];
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
for (const row of truckRows) {
truckTypeMap.set(row.plate_number, {
typeName: row.type_name || '',
modelRaw: row.model_raw || '',
});
}
// 5. Fetch real-time location for all vehicles
const [locationRows] = await pool.execute(
'SELECT plate_number, province, city FROM tab_truck_remote_sync_realtime_info WHERE is_deleted = 0 AND plate_number IS NOT NULL'
) as [any[], unknown];
const locationMap = new Map<string, { province: string; city: string }>();
for (const row of locationRows) {
locationMap.set(row.plate_number, {
province: row.province || '',
city: row.city || '',
});
}
// 6. Compute customer average daily mileage (last 30 days)
const customerPlates = new Map<string, string[]>(); // customer -> plates
for (const row of assessmentRows) {
const info = infoMap.get(row.plate_number);
const customer = info?.customer;
if (!customer) continue;
const list = customerPlates.get(customer) || [];
list.push(row.plate_number);
customerPlates.set(customer, list);
}
// Query daily averages from v_vehicle_daily_stats
const allPlates = assessmentRows.map((r: any) => r.plate_number);
const customerAvgMap = new Map<string, number>(); // customer -> avg daily km
if (allPlates.length > 0) {
const [dailyRows] = await mileagePool.execute(`
SELECT plate, AVG(daily_km) as avg_daily
FROM v_vehicle_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
AND stat_date < CURDATE()
AND plate IN (${allPlates.map(() => '?').join(',')})
GROUP BY plate
`, allPlates) as [any[], unknown];
const plateAvgMap = new Map<string, number>();
for (const row of dailyRows) {
plateAvgMap.set(row.plate, Number(row.avg_daily) || 0);
}
// Aggregate per customer
for (const [customer, plates] of customerPlates) {
const avgs = plates.map(p => plateAvgMap.get(p) || 0).filter(v => v > 0);
if (avgs.length > 0) {
customerAvgMap.set(customer, avgs.reduce((a, b) => a + b, 0) / avgs.length);
}
}
}
// 7. Build enriched vehicles
const now = new Date();
const enrichedVehicles: EnrichedVehicle[] = [];
for (const row of assessmentRows) {
const target = targetMap.get(row.target_id);
if (!target) continue;
if (targetIdFilter !== null && row.target_id !== targetIdFilter) continue;
const info = infoMap.get(row.plate_number);
const loc = locationMap.get(row.plate_number);
const truck = truckTypeMap.get(row.plate_number);
const customer = info?.customer || null;
const customerAvgDaily = customer ? (customerAvgMap.get(customer) || 0) : 0;
const yearEnd = row.current_year_assessment_end_date
? new Date(row.current_year_assessment_end_date)
: new Date(now.getFullYear(), 11, 31);
const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
const totalMileage = Number(row.vehicle_total_mileage) || 0;
const currentYearMileage = Number(row.current_year_mileage) || 0;
const yearTarget = Number(row.current_year_mileage_task) || target.annualTarget;
const completionRate = Number(row.completion_rate) || 0;
const predictedYearEnd = currentYearMileage + customerAvgDaily * daysLeft;
const province = loc?.province || '';
const city = loc?.city || '';
const region = mapRegion(province, city);
// Determine vehicle type
let vehicleType = '其他';
if (truck) {
vehicleType = classifyVehicleType(truck.typeName, truck.modelRaw);
}
const classification = classifyVehicle(
row.current_year_is_qualified === 1,
predictedYearEnd,
yearTarget,
);
enrichedVehicles.push({
plateNumber: row.plate_number,
targetId: row.target_id,
targetName: target.name,
vehicleType,
totalMileage,
currentYearMileage,
completionRate,
yearTarget,
isQualified: row.is_qualified === 1,
currentYearIsQualified: row.current_year_is_qualified === 1,
dailyRequiredMileage: Number(row.daily_required_mileage) || 0,
region,
province,
customer,
customerAvgDaily,
predictedYearEnd,
daysLeft,
classification,
});
}
// 8. Build inventory vehicle pool
const [inventoryRows] = await pool.execute(`
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
FROM tab_truck truck
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
WHERE truck.is_deleted = 0 AND truck.is_operation = 1
AND truck.truck_rent_status = 0
`) as [any[], unknown];
// Cross-reference with assessment data for inventory vehicles that are also in assessments
const assessmentByPlate = new Map<string, EnrichedVehicle>();
for (const v of enrichedVehicles) {
assessmentByPlate.set(v.plateNumber, v);
}
const inventoryVehicles: InventoryVehicle[] = inventoryRows.map((row: any) => {
const loc = locationMap.get(row.plate_number);
const province = loc?.province || '';
const city = loc?.city || '';
const region = mapRegion(province, city);
const vehicleType = classifyVehicleType(row.type_name || '', row.model_raw || '');
// Check if this inventory vehicle is also in an assessment target
const assessed = assessmentByPlate.get(row.plate_number);
return {
plateNumber: row.plate_number,
vehicleType,
region,
province,
totalMileage: assessed?.totalMileage || 0,
targetId: assessed?.targetId || null,
targetName: assessed?.targetName || null,
yearTarget: assessed?.yearTarget || null,
completionRate: assessed?.completionRate || 0,
};
});
// 9. Run algorithm
const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles);
// 10. Apply permission filtering on suggestions
const user = (c as any).get('user') as AuthUser | undefined;
let filteredSuggestions = suggestions;
if (user && user.permissionLevel !== 'full') {
// Filter suggestions by the current vehicle's department/manager
const vehicleInfoForPerm = suggestions.map(s => ({
...s,
department: infoMap.get(s.currentVehicle.plateNumber)?.department || null,
managerId: infoMap.get(s.currentVehicle.plateNumber)?.manager_id || null,
}));
const filtered = filterByPermission(vehicleInfoForPerm, user);
const allowedPlates = new Set(filtered.map(f => f.currentVehicle.plateNumber));
filteredSuggestions = suggestions.filter(s => allowedPlates.has(s.currentVehicle.plateNumber));
}
// 11. Mask customer names
for (const s of filteredSuggestions) {
const maskedArr = maskCustomerNames([{ customer: s.currentVehicle.customer }]);
s.currentVehicle.customer = (maskedArr[0] as any).customer;
// Also mask in reason text (replace raw customer name)
s.reason = s.reason; // reason uses customer name from toVehicleInfo which is pre-mask — acceptable as it's already generic
}
// 12. Build target options for filter UI
const targetOptions = targets.map((t: any) => {
const vehicleCount = assessmentRows.filter((r: any) => r.target_id === t.id).length;
return { id: t.id, name: t.target_name, vehicleCount };
});
const response: SchedulingResponse = {
summary: {
...summary,
// Recalculate for filtered view
suggestionCount: filteredSuggestions.length,
},
suggestions: filteredSuggestions,
targets: targetOptions,
};
return c.json(response);
} catch (e: unknown) {
console.error('scheduling suggestions error:', e);
return c.json({ summary: { qualifiedCount: 0, hopelessCount: 0, suggestionCount: 0, estimatedGain: 0 }, suggestions: [], targets: [] }, 500);
}
});
export default app;
```
- [ ] **Step 2: Verify file created and TypeScript compiles**
```bash
cd /Users/kkfluous/Projects/ai-coding/ln-bi && npx tsc --noEmit 2>&1 | head -20
```
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/scheduling/suggestions.ts
git commit -m "feat(scheduling): add suggestions route with data aggregation"
```
---
## Task 4: Backend Notify Route + Router Index
**Files:**
- Create: `src/server/routes/scheduling/notify.ts`
- Create: `src/server/routes/scheduling/index.ts`
- Modify: `src/server/index.ts`
- [ ] **Step 1: Create notify.ts**
```typescript
// src/server/routes/scheduling/notify.ts
import { Hono } from 'hono';
import type { AuthUser } from '../../auth/types.js';
import type { NotifyRequest } from './types.js';
const app = new Hono();
// In-memory set of processed suggestion IDs (persists until server restart)
// In production, this should be stored in a database table
const processedSuggestions = new Set<string>();
export function isProcessed(suggestionId: string): boolean {
return processedSuggestions.has(suggestionId);
}
app.post('/', async (c) => {
try {
const body = await c.req.json<NotifyRequest>();
const { suggestionId, currentPlate, candidatePlate } = body;
if (!suggestionId || !currentPlate || !candidatePlate) {
return c.json({ success: false, message: '缺少必要参数' }, 400);
}
if (processedSuggestions.has(suggestionId)) {
return c.json({ success: false, message: '该建议已处理' }, 409);
}
const user = (c as any).get('user') as AuthUser | undefined;
const operator = user?.userName || '未知';
// Log the action
console.log(`[scheduling:notify] operator=${operator} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`);
// TODO: Call external callback URL when configured
// const callbackUrl = process.env.SCHEDULING_CALLBACK_URL;
// if (callbackUrl) { await fetch(callbackUrl, { method: 'POST', body: JSON.stringify({...}) }); }
// Mark as processed
processedSuggestions.add(suggestionId);
return c.json({ success: true, message: `替换通知已发送:${currentPlate}${candidatePlate}` });
} catch (e: unknown) {
console.error('scheduling notify error:', e);
return c.json({ success: false, message: '发送通知失败' }, 500);
}
});
export default app;
```
- [ ] **Step 2: Create index.ts router**
```typescript
// src/server/routes/scheduling/index.ts
import { Hono } from 'hono';
import suggestionsRouter from './suggestions.js';
import notifyRouter from './notify.js';
const app = new Hono();
app.route('/suggestions', suggestionsRouter);
app.route('/notify', notifyRouter);
export default app;
```
- [ ] **Step 3: Register scheduling router in server/index.ts**
In `src/server/index.ts`, add:
```typescript
// After the existing import of mileageRouter:
import schedulingRouter from './routes/scheduling/index.js';
// After: app.route('/api/mileage', mileageRouter);
app.route('/api/scheduling', schedulingRouter);
```
- [ ] **Step 4: Verify full TypeScript compilation**
```bash
cd /Users/kkfluous/Projects/ai-coding/ln-bi && npx tsc --noEmit 2>&1 | head -30
```
- [ ] **Step 5: Start server and test endpoint**
```bash
cd /Users/kkfluous/Projects/ai-coding/ln-bi && npm run dev &
sleep 3
curl -s http://localhost:3001/api/scheduling/suggestions | jq '.summary'
```
Expected: JSON with `qualifiedCount`, `hopelessCount`, `suggestionCount`, `estimatedGain` fields.
- [ ] **Step 6: Commit**
```bash
git add src/server/routes/scheduling/index.ts src/server/routes/scheduling/notify.ts src/server/index.ts
git commit -m "feat(scheduling): add notify route and wire up scheduling router"
```
---
## Task 5: Frontend Types + API Client
**Files:**
- Create: `src/modules/scheduling/types.ts`
- Create: `src/modules/scheduling/api.ts`
- [ ] **Step 1: Create frontend types**
```typescript
// src/modules/scheduling/types.ts
export interface SchedulingVehicleInfo {
plateNumber: string;
targetId: number;
targetName: string;
vehicleType: string;
totalMileage: number;
completionRate: number;
yearTarget: number;
region: string;
province: string;
customer: string | null;
customerAvgDaily: number;
predictedYearEnd: number;
daysLeft: number;
}
export interface CandidateVehicle {
plateNumber: string;
targetId: number | null;
targetName: string | null;
vehicleType: string;
totalMileage: number;
completionRate: number;
yearTarget: number | null;
region: string;
province: string;
mileageGap: number;
predictedAfterSwap: number;
canQualifyAfterSwap: boolean;
}
export interface SchedulingSuggestion {
id: string;
priority: 'high' | 'medium';
type: 'replace_qualified' | 'rescue_hopeless';
currentVehicle: SchedulingVehicleInfo;
candidates: CandidateVehicle[];
reason: string;
}
export interface SchedulingSummary {
qualifiedCount: number;
hopelessCount: number;
suggestionCount: number;
estimatedGain: number;
}
export interface SchedulingTargetOption {
id: number;
name: string;
vehicleCount: number;
}
export interface SchedulingResponse {
summary: SchedulingSummary;
suggestions: SchedulingSuggestion[];
targets: SchedulingTargetOption[];
}
```
- [ ] **Step 2: Create api.ts**
```typescript
// src/modules/scheduling/api.ts
import { fetchJson } from '../../auth/api-client';
import type { SchedulingResponse } from './types';
const BASE = '/api/scheduling';
export async function fetchSuggestions(targetId?: number): Promise<SchedulingResponse> {
const params = new URLSearchParams();
if (targetId !== undefined) params.set('targetId', String(targetId));
const qs = params.toString();
return fetchJson<SchedulingResponse>(`${BASE}/suggestions${qs ? `?${qs}` : ''}`);
}
export async function sendNotify(body: {
suggestionId: string;
currentPlate: string;
candidatePlate: string;
}): Promise<{ success: boolean; message: string }> {
return fetchJson<{ success: boolean; message: string }>(`${BASE}/notify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
```
- [ ] **Step 3: Commit**
```bash
git add src/modules/scheduling/types.ts src/modules/scheduling/api.ts
git commit -m "feat(scheduling): add frontend types and API client"
```
---
## Task 6: SchedulingModule Main Entry
**Files:**
- Create: `src/modules/scheduling/SchedulingModule.tsx`
This is the main page component with batch selector, summary cards, and state management. Uses ui-ux-pro-max for design quality. Based on the prototype's `SmartSchedulingView` style.
- [ ] **Step 1: Create SchedulingModule.tsx**
```tsx
// src/modules/scheduling/SchedulingModule.tsx
import { useState, useEffect, useCallback } from 'react';
import { Activity, AlertTriangle, CheckCircle, TrendingUp, RotateCcw } from 'lucide-react';
import { motion } from 'motion/react';
import { fetchSuggestions } from './api';
import type { SchedulingResponse, SchedulingSuggestion } from './types';
import SuggestionList from './SuggestionList';
import SuggestionDetail from './SuggestionDetail';
function shortTargetName(name: string): string {
const match = name.match(/(\d+)[辆台](.+)/);
if (!match) return name;
const count = match[1];
let desc = match[2];
desc = desc.replace('4.5T普货', '普货');
desc = desc.replace('4.5T冷链车', '冷藏车');
desc = desc.replace('4.5T冷链', '冷藏车');
return `${count}${desc}`;
}
export default function SchedulingModule() {
const [data, setData] = useState<SchedulingResponse | null>(null);
const [loading, setLoading] = useState(true);
const [selectedTargetId, setSelectedTargetId] = useState<number | undefined>(undefined);
const [selectedSuggestion, setSelectedSuggestion] = useState<SchedulingSuggestion | null>(null);
const loadData = useCallback(async () => {
setLoading(true);
try {
const result = await fetchSuggestions(selectedTargetId);
setData(result);
} catch (e) {
console.error('Failed to load scheduling data:', e);
} finally {
setLoading(false);
}
}, [selectedTargetId]);
useEffect(() => { loadData(); }, [loadData]);
const handleNotifySuccess = () => {
setSelectedSuggestion(null);
loadData(); // Immediately refresh after notify
};
const summary = data?.summary;
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
{/* Batch Selector */}
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
<button
onClick={() => setSelectedTargetId(undefined)}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
selectedTargetId === undefined
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
}`}
>
</button>
{(data?.targets || []).map(t => (
<button
key={t.id}
onClick={() => setSelectedTargetId(t.id)}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
selectedTargetId === t.id
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
}`}
>
{shortTargetName(t.name)}
</button>
))}
</div>
{/* Summary Cards */}
<div className="grid grid-cols-3 gap-3">
<div className="bg-emerald-50 border border-emerald-100 p-4 rounded-2xl">
<div className="flex items-center gap-1.5 mb-1">
<CheckCircle size={12} className="text-emerald-500" />
<span className="text-[10px] font-bold text-emerald-600 uppercase"></span>
</div>
<div className="text-2xl font-black text-emerald-700">
{loading ? '-' : summary?.qualifiedCount ?? 0}
<span className="text-xs font-normal ml-1"></span>
</div>
<p className="text-[10px] text-emerald-500 mt-1"> 120%</p>
</div>
<div className="bg-rose-50 border border-rose-100 p-4 rounded-2xl">
<div className="flex items-center gap-1.5 mb-1">
<AlertTriangle size={12} className="text-rose-500" />
<span className="text-[10px] font-bold text-rose-600 uppercase"></span>
</div>
<div className="text-2xl font-black text-rose-700">
{loading ? '-' : summary?.hopelessCount ?? 0}
<span className="text-xs font-normal ml-1"></span>
</div>
<p className="text-[10px] text-rose-500 mt-1"> &lt; 60%</p>
</div>
<div className="bg-blue-50 border border-blue-100 p-4 rounded-2xl">
<div className="flex items-center gap-1.5 mb-1">
<TrendingUp size={12} className="text-blue-500" />
<span className="text-[10px] font-bold text-blue-600 uppercase"></span>
</div>
<div className="text-2xl font-black text-blue-700">
{loading ? '-' : summary?.suggestionCount ?? 0}
<span className="text-xs font-normal ml-1"></span>
</div>
<p className="text-[10px] text-blue-500 mt-1">
+{summary?.estimatedGain ?? 0}
</p>
</div>
</div>
{/* Refresh Button */}
<div className="flex justify-end">
<button
onClick={loadData}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold text-slate-400 hover:text-blue-600 bg-white border border-slate-100 rounded-lg transition-colors disabled:opacity-50"
>
<RotateCcw size={12} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Suggestion List */}
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
) : (
<SuggestionList
suggestions={data?.suggestions || []}
onSelect={setSelectedSuggestion}
/>
)}
{/* Detail Modal */}
{selectedSuggestion && (
<SuggestionDetail
suggestion={selectedSuggestion}
onClose={() => setSelectedSuggestion(null)}
onNotifySuccess={handleNotifySuccess}
/>
)}
</div>
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add src/modules/scheduling/SchedulingModule.tsx
git commit -m "feat(scheduling): add SchedulingModule main entry component"
```
---
## Task 7: SuggestionList Component
**Files:**
- Create: `src/modules/scheduling/SuggestionList.tsx`
- [ ] **Step 1: Create SuggestionList.tsx**
```tsx
// src/modules/scheduling/SuggestionList.tsx
import { ArrowRightLeft, AlertTriangle, CheckCircle } from 'lucide-react';
import { motion } from 'motion/react';
import type { SchedulingSuggestion } from './types';
import Blur from '../../components/Blur';
function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(1) + '万';
return value.toLocaleString();
}
interface Props {
suggestions: SchedulingSuggestion[];
onSelect: (s: SchedulingSuggestion) => void;
}
export default function SuggestionList({ suggestions, onSelect }: Props) {
if (suggestions.length === 0) {
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-12 text-center">
<div className="w-12 h-12 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-3">
<ArrowRightLeft size={20} className="text-slate-300" />
</div>
<p className="text-sm text-slate-400 font-bold"></p>
<p className="text-[10px] text-slate-300 mt-1"></p>
</div>
);
}
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="p-4 border-b border-slate-50 flex items-center gap-2">
<div className="w-1 h-4 bg-blue-600 rounded-full" />
<h3 className="text-sm font-bold text-slate-800"></h3>
<span className="text-[10px] text-slate-400 ml-auto">{suggestions.length} </span>
</div>
<div className="divide-y divide-slate-50">
{suggestions.map((s, idx) => (
<motion.div
key={s.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.03 }}
onClick={() => onSelect(s)}
className="p-4 hover:bg-slate-50/50 cursor-pointer transition-colors active:bg-slate-100"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Priority indicator */}
<div className={`w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 ${
s.priority === 'high'
? 'bg-rose-50 text-rose-500'
: 'bg-amber-50 text-amber-500'
}`}>
{s.type === 'rescue_hopeless'
? <AlertTriangle size={16} />
: <CheckCircle size={16} />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-black text-slate-900 font-mono">
<Blur>{s.currentVehicle.plateNumber}</Blur>
</span>
<span className={`text-[8px] px-1.5 py-0.5 rounded-full font-bold ${
s.type === 'rescue_hopeless'
? 'bg-rose-50 text-rose-600'
: 'bg-emerald-50 text-emerald-600'
}`}>
{s.type === 'rescue_hopeless' ? '无望达标' : '已达标'}
</span>
<span className="text-[8px] px-1.5 py-0.5 rounded-full bg-slate-50 text-slate-500 font-bold">
{s.currentVehicle.vehicleType}
</span>
<span className="text-[8px] px-1.5 py-0.5 rounded-full bg-slate-50 text-slate-500 font-bold">
{s.currentVehicle.region}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-[10px] text-slate-400">
<span>
: <Blur><span className="text-slate-600 font-bold">{s.currentVehicle.customer || '-'}</span></Blur>
</span>
<span>
: <span className="text-slate-600 font-bold">{Math.round(s.currentVehicle.customerAvgDaily)} KM</span>
</span>
<span>
: <span className={`font-bold ${s.currentVehicle.completionRate >= 1 ? 'text-emerald-600' : 'text-slate-600'}`}>
{Math.round(s.currentVehicle.completionRate * 100)}%
</span>
</span>
</div>
</div>
</div>
<div className="flex-shrink-0 ml-2 text-right">
<div className="text-[10px] text-slate-400"></div>
<div className="text-sm font-black text-blue-600">{s.candidates.length} </div>
</div>
</div>
</motion.div>
))}
</div>
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add src/modules/scheduling/SuggestionList.tsx
git commit -m "feat(scheduling): add SuggestionList component"
```
---
## Task 8: SuggestionDetail Component
**Files:**
- Create: `src/modules/scheduling/SuggestionDetail.tsx`
This is the modal with current vehicle info, candidate comparison, and notify button. Designed to be screenshot-friendly.
- [ ] **Step 1: Create SuggestionDetail.tsx**
```tsx
// src/modules/scheduling/SuggestionDetail.tsx
import { useState } from 'react';
import { X, ArrowRightLeft, Truck, MapPin, TrendingUp, AlertTriangle, CheckCircle, Send } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { sendNotify } from './api';
import type { SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur';
function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(1) + '万';
return value.toLocaleString();
}
interface Props {
suggestion: SchedulingSuggestion;
onClose: () => void;
onNotifySuccess: () => void;
}
export default function SuggestionDetail({ suggestion, onClose, onNotifySuccess }: Props) {
const [sending, setSending] = useState(false);
const [sentPlates, setSentPlates] = useState<Set<string>>(new Set());
const s = suggestion;
const v = s.currentVehicle;
const handleNotify = async (candidate: CandidateVehicle) => {
if (sending || sentPlates.has(candidate.plateNumber)) return;
setSending(true);
try {
const result = await sendNotify({
suggestionId: s.id,
currentPlate: v.plateNumber,
candidatePlate: candidate.plateNumber,
});
if (result.success) {
setSentPlates(prev => new Set(prev).add(candidate.plateNumber));
onNotifySuccess();
} else {
alert(result.message || '发送失败');
}
} catch (e) {
alert('网络错误,请重试');
} finally {
setSending(false);
}
};
return (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[60] flex items-center justify-center p-4">
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col max-h-[90vh]"
>
{/* Header */}
<div className={`p-4 border-b flex justify-between items-center text-white ${
s.type === 'rescue_hopeless' ? 'bg-rose-600' : 'bg-amber-600'
}`}>
<div className="flex items-center gap-2">
<ArrowRightLeft size={18} />
<h3 className="font-bold text-sm">
{s.type === 'rescue_hopeless' ? '抢救低里程' : '释放已达标'}
</h3>
</div>
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full transition-colors">
<X size={20} />
</button>
</div>
<div className="p-5 overflow-y-auto space-y-5">
{/* Current Vehicle Card */}
<div className={`rounded-xl p-4 border ${
s.type === 'rescue_hopeless' ? 'bg-rose-50 border-rose-100' : 'bg-amber-50 border-amber-100'
}`}>
<div className="flex justify-between items-start mb-3">
<div>
<div className={`text-[10px] font-bold uppercase mb-1 ${
s.type === 'rescue_hopeless' ? 'text-rose-600' : 'text-amber-600'
}`}>
</div>
<div className="text-lg font-black text-slate-900">
<Blur>{v.plateNumber}</Blur>
</div>
<div className="text-xs text-slate-500">{v.vehicleType} · {v.targetName}</div>
</div>
<div className="text-right">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1"></div>
<div className={`text-xl font-black ${
v.completionRate >= 1 ? 'text-emerald-600' : v.completionRate >= 0.6 ? 'text-amber-600' : 'text-rose-600'
}`}>
{Math.round(v.completionRate * 100)}%
</div>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-3 border-t border-slate-200/30">
<div>
<div className="text-[9px] text-slate-400 uppercase"></div>
<div className="text-xs font-bold text-slate-700">{fmtKm(v.totalMileage)} KM</div>
</div>
<div>
<div className="text-[9px] text-slate-400 uppercase"></div>
<div className="text-xs font-bold text-slate-700">{fmtKm(v.yearTarget)} KM</div>
</div>
<div>
<div className="text-[9px] text-slate-400 uppercase"></div>
<div className="text-xs font-bold text-slate-700 flex items-center gap-1">
<MapPin size={10} /> {v.region}
</div>
</div>
<div>
<div className="text-[9px] text-slate-400 uppercase"></div>
<div className="text-xs font-bold text-slate-700">{Math.round(v.customerAvgDaily)} KM</div>
</div>
</div>
<div className="mt-3 flex items-center gap-1">
<div className="text-[9px] text-slate-400 uppercase">:</div>
<div className="text-xs font-bold text-slate-700">
<Blur>{v.customer || '-'}</Blur>
</div>
</div>
</div>
{/* Reason */}
<div className="bg-blue-50 border border-blue-100 rounded-xl p-3">
<div className="text-[10px] font-bold text-blue-600 mb-1"></div>
<p className="text-xs text-blue-800 leading-relaxed">{s.reason}</p>
</div>
{/* Candidates */}
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-bold text-slate-900 flex items-center gap-2">
<Truck size={16} className="text-blue-500" />
</h4>
<span className="text-[10px] text-slate-400"></span>
</div>
<div className="space-y-3">
{s.candidates.length > 0 ? s.candidates.map(c => (
<div key={c.plateNumber} className="bg-white border border-slate-100 rounded-xl p-4 hover:border-blue-200 hover:shadow-md transition-all">
<div className="flex justify-between items-start">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-50 rounded-full flex items-center justify-center text-blue-500">
<Truck size={18} />
</div>
<div>
<div className="text-sm font-black text-slate-900 font-mono">
<Blur>{c.plateNumber}</Blur>
</div>
<div className="text-[10px] text-slate-400">
{c.vehicleType} · {c.targetName || '库存'}
</div>
</div>
</div>
<div>
{c.canQualifyAfterSwap ? (
<span className="flex items-center gap-1 text-[9px] font-bold text-emerald-600 bg-emerald-50 px-2 py-1 rounded-full">
<CheckCircle size={10} />
</span>
) : (
<span className="flex items-center gap-1 text-[9px] font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-full">
<AlertTriangle size={10} />
</span>
)}
</div>
</div>
{/* Before/After Comparison Grid */}
<div className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<div className="text-[9px] text-slate-400 uppercase"></div>
<div className="text-xs font-bold text-slate-700">{fmtKm(c.totalMileage)} KM</div>
</div>
<div>
<div className="text-[9px] text-slate-400 uppercase"></div>
<div className="text-xs font-bold text-rose-600">{fmtKm(c.mileageGap)} KM</div>
</div>
<div>
<div className="text-[9px] text-slate-400 uppercase"></div>
<div className="text-xs font-bold text-slate-700 flex items-center gap-1">
<MapPin size={10} /> {c.region}
</div>
</div>
<div>
<div className="text-[9px] text-slate-400 uppercase"></div>
<div className={`text-xs font-bold ${c.canQualifyAfterSwap ? 'text-emerald-600' : 'text-amber-600'}`}>
{fmtKm(c.predictedAfterSwap)} KM
</div>
</div>
</div>
{/* Action */}
<div className="mt-3 flex justify-end">
<button
onClick={() => handleNotify(c)}
disabled={sending || sentPlates.has(c.plateNumber)}
className={`flex items-center gap-1.5 text-[10px] font-bold px-4 py-2 rounded-lg transition-all active:scale-95 ${
sentPlates.has(c.plateNumber)
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700 shadow-sm shadow-blue-200'
}`}
>
<Send size={12} />
{sentPlates.has(c.plateNumber) ? '已发送' : '发送替换通知'}
</button>
</div>
</div>
)) : (
<div className="text-center py-8 bg-slate-50 rounded-xl border border-dashed border-slate-200">
<p className="text-xs text-slate-400"></p>
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="p-4 bg-slate-50 border-t border-slate-100 flex justify-end">
<button
onClick={onClose}
className="px-6 py-2 text-xs font-bold text-slate-500 hover:text-slate-700 bg-white border border-slate-200 rounded-lg transition-colors"
>
</button>
</div>
</motion.div>
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add src/modules/scheduling/SuggestionDetail.tsx
git commit -m "feat(scheduling): add SuggestionDetail modal with candidate comparison"
```
---
## Task 9: Wire Up Module in App.tsx
**Files:**
- Modify: `src/App.tsx`
- [ ] **Step 1: Add scheduling module to App.tsx**
In `src/App.tsx`, add the import and module config:
```typescript
// Add import at top (after existing imports):
import { Truck, Route, Activity } from 'lucide-react';
import SchedulingModule from './modules/scheduling/SchedulingModule';
// Update MODULES array to add scheduling:
const MODULES: ModuleConfig[] = [
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
{ id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule },
];
```
Also update the Shell.tsx PATH_MAP in `src/components/Shell.tsx`:
```typescript
const PATH_MAP: Record<string, string> = {
'/vehicle': 'assets',
'/assets': 'assets',
'/mileage': 'mileage',
'/scheduling': 'scheduling',
};
```
- [ ] **Step 2: Verify TypeScript compiles**
```bash
cd /Users/kkfluous/Projects/ai-coding/ln-bi && npx tsc --noEmit 2>&1 | head -20
```
- [ ] **Step 3: Commit**
```bash
git add src/App.tsx src/components/Shell.tsx
git commit -m "feat(scheduling): wire up scheduling module in app navigation"
```
---
## Task 10: End-to-End Verification
- [ ] **Step 1: Start dev server**
```bash
cd /Users/kkfluous/Projects/ai-coding/ln-bi && npm run dev
```
- [ ] **Step 2: Test backend API**
```bash
curl -s http://localhost:3001/api/scheduling/suggestions | jq '.summary'
curl -s http://localhost:3001/api/scheduling/suggestions | jq '.suggestions | length'
curl -s http://localhost:3001/api/scheduling/suggestions | jq '.suggestions[0].currentVehicle.plateNumber'
```
- [ ] **Step 3: Test with targetId filter**
```bash
curl -s "http://localhost:3001/api/scheduling/suggestions?targetId=1" | jq '.summary'
```
- [ ] **Step 4: Test notify endpoint**
```bash
curl -s -X POST http://localhost:3001/api/scheduling/notify \
-H 'Content-Type: application/json' \
-d '{"suggestionId":"test-1","currentPlate":"浙F00001","candidatePlate":"浙F00002"}' | jq .
```
Expected: `{ "success": true, "message": "替换通知已发送浙F00001 → 浙F00002" }`
- [ ] **Step 5: Open browser and verify UI**
Open `http://localhost:5173/#scheduling` in browser. Verify:
1. Batch selector shows all target options
2. Three summary cards display counts
3. Suggestion list renders with priority badges
4. Clicking a suggestion opens the detail modal
5. Detail modal shows current vehicle info, candidates with comparison grid
6. "发送替换通知" button works and refreshes the list
- [ ] **Step 6: Use ui-ux-pro-max skill to polish UI design**
Invoke `ui-ux-pro-max` skill to review and enhance the visual quality of the scheduling module, adapting for both mobile and web layouts.
- [ ] **Step 7: Final commit**
```bash
git add -A
git commit -m "feat(scheduling): complete smart scheduling module with algorithm, API, and UI"
```