feat: improve asset inventory statistics and responsive UX (v1.1.16)

This commit is contained in:
shishengliang
2026-09-23 11:07:36 +08:00
parent 9b0980e349
commit b0cba26fbb
30 changed files with 626 additions and 498 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.15", "version": "1.1.16",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.15", "version": "1.1.16",
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1", "@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0", "@hono/node-server": "^1.13.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"private": true, "private": true,
"version": "1.1.15", "version": "1.1.16",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
+27 -65
View File
@@ -1,71 +1,33 @@
import { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useMemo, useRef, useId } from 'react';
import { ChevronDown } from 'lucide-react'; import { ChevronDown } from 'lucide-react';
export function SearchSelect({ value, onChange, options, placeholder, className }: { export function SearchSelect({ value, onChange, options, placeholder, className }: {
value: string; value: string; onChange: (value: string) => void; options: string[]; placeholder: string; className?: string;
onChange: (v: string) => void;
options: string[];
placeholder: string;
className?: string;
}) { }) {
const [open, setOpen] = useState(false); const [open,setOpen]=useState(false);
const [query, setQuery] = useState(''); const [query,setQuery]=useState('');
const ref = useRef<HTMLDivElement>(null); const [active,setActive]=useState(0);
const ref=useRef<HTMLDivElement>(null);
useEffect(() => { const inputRef=useRef<HTMLInputElement>(null);
const handler = (e: MouseEvent) => { const id=useId();
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); const filtered=useMemo(()=>options.filter(option=>option.toLowerCase().includes(query.toLowerCase())),[options,query]);
}; const choices=['',...filtered];
document.addEventListener('mousedown', handler); const choose=(option:string)=>{onChange(option);setQuery('');setOpen(false);};
return () => document.removeEventListener('mousedown', handler); useEffect(()=>{const outside=(event:MouseEvent)=>{if(ref.current && !ref.current.contains(event.target as Node))setOpen(false);};document.addEventListener('mousedown',outside);return()=>document.removeEventListener('mousedown',outside);},[]);
}, []); useEffect(()=>{if(open)document.getElementById(`${id}-${active}`)?.scrollIntoView({block:'nearest'});},[open,active,id]);
return <div ref={ref} className="relative">
const filtered = useMemo(() => { <div className={`flex items-center rounded-lg border border-slate-200 bg-white shadow-sm focus-within:ring-2 focus-within:ring-blue-500/20 ${className || 'px-3 py-2 text-sm'}`}>
if (!query) return options; <input ref={inputRef} type="text" role="combobox" aria-label={placeholder} aria-expanded={open} aria-controls={`${id}-list`} aria-autocomplete="list" aria-activedescendant={open?`${id}-${active}`:undefined}
const q = query.toLowerCase(); className="min-w-0 flex-1 bg-transparent text-inherit outline-none" placeholder={placeholder} value={open?query:value}
return options.filter((o) => o.toLowerCase().includes(q)); onClick={()=>setOpen(true)} onFocus={()=>{setOpen(true);setQuery('');setActive(0);}}
}, [options, query]); onChange={e=>{setQuery(e.target.value);setActive(0);setOpen(true);}}
onKeyDown={e=>{if(e.key==='Escape'){e.stopPropagation();setOpen(false);}else if(e.key==='ArrowDown'||e.key==='ArrowUp'){e.preventDefault();setOpen(true);setActive(i=>(i+(e.key==='ArrowDown'?1:-1)+choices.length)%choices.length);}else if(e.key==='Enter'&&open){e.preventDefault();choose(choices[active]??'');}else if(e.key==='Tab')setOpen(false);}}
const displayValue = value || ''; />
<button type="button" tabIndex={-1} aria-label={open?'收起选项':'展开选项'} onMouseDown={e=>e.preventDefault()} onClick={()=>{setQuery('');setActive(0);setOpen(v=>!v);}} className="p-1 text-slate-500"><ChevronDown size={15} className={open?'rotate-180':''}/></button>
return (
<div ref={ref} className="relative">
<div
className={`flex items-center bg-white border border-gray-200 rounded-lg shadow-sm cursor-pointer transition-all focus-within:ring-2 focus-within:ring-blue-500/20 focus-within:border-blue-500 ${className || 'text-xs py-1.5 px-2'}`}
onClick={() => setOpen(!open)}
>
<input
type="text"
className="flex-1 outline-none bg-transparent min-w-0 text-inherit"
placeholder={displayValue || placeholder}
value={open ? query : displayValue}
onChange={(e) => { setQuery(e.target.value); if (!open) setOpen(true); }}
onFocus={() => { setOpen(true); setQuery(''); }}
/>
<ChevronDown size={14} className={`text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</div>
{open && (
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-xl max-h-48 overflow-auto">
<div
className="px-2 py-1.5 text-xs text-gray-400 cursor-pointer hover:bg-gray-50"
onClick={() => { onChange(''); setQuery(''); setOpen(false); }}
>
{placeholder}
</div>
{filtered.map((o) => (
<div
key={o}
className={`px-2 py-1.5 text-xs cursor-pointer hover:bg-blue-50 transition-colors ${o === value ? 'bg-blue-50 text-blue-600 font-bold' : 'text-gray-700'}`}
onClick={() => { onChange(o); setQuery(''); setOpen(false); }}
>
{o}
</div>
))}
{filtered.length === 0 && (
<div className="px-2 py-3 text-xs text-gray-400 text-center">无匹配项</div>
)}
</div>
)}
</div> </div>
); {open && <div id={`${id}-list`} role="listbox" aria-label={placeholder} className="absolute left-0 right-0 z-50 mt-1 max-h-56 overflow-auto rounded-xl border border-slate-200 bg-white p-1 shadow-xl">
{choices.map((option,index)=><button key={option} type="button" id={`${id}-${index}`} role="option" aria-selected={option===value} tabIndex={-1} onMouseDown={e=>e.preventDefault()} onClick={()=>choose(option)} className={`block min-h-10 w-full rounded-lg px-3 py-2 text-left text-xs ${index===active?'bg-blue-50 text-blue-700':'text-slate-700 hover:bg-slate-50'}`}>{option||'全部'}</button>)}
{filtered.length===0 && <p className="p-3 text-center text-xs text-slate-500">无匹配项</p>}
</div>}
</div>;
} }
+29 -29
View File
@@ -1,3 +1,4 @@
import './assets.css';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { import {
Truck, Truck,
@@ -129,6 +130,8 @@ export default function AssetsModule() {
const [regionChartData, setRegionChartData] = useState<{ name: string; value: number }[]>([]); const [regionChartData, setRegionChartData] = useState<{ name: string; value: number }[]>([]);
// Modal filter state // Modal filter state
const [modalError, setModalError] = useState<string | null>(null);
const [modalRetry, setModalRetry] = useState(0);
const [modalFilters, setModalFilters] = useState({ plateNumber: '', model: '', brand: '', location: '' }); const [modalFilters, setModalFilters] = useState({ plateNumber: '', model: '', brand: '', location: '' });
const [isModalFilterExpanded, setIsModalFilterExpanded] = useState(false); const [isModalFilterExpanded, setIsModalFilterExpanded] = useState(false);
@@ -227,30 +230,22 @@ export default function AssetsModule() {
.catch(() => setRegionChartData([])); .catch(() => setRegionChartData([]));
}, [regionChartView, selectedSubject]); }, [regionChartView, selectedSubject]);
// Load modal vehicles // Keep stale requests from overwriting a newer selection or a closed dialog.
useEffect(() => { useEffect(() => {
if (!showPlateNumbers) { let cancelled = false;
setModalVehicles([]); setModalVehicles([]);
setModalWeeklyDetail([]); setModalWeeklyDetail([]);
return; setModalError(null);
} if (!showPlateNumbers) return;
setModalLoading(true); setModalLoading(true);
const request = buildVehicleModalRequest(showPlateNumbers, selectedSubject); const request = buildVehicleModalRequest(showPlateNumbers, selectedSubject);
if (request.kind === 'weekly') { const load = request.kind === 'weekly'
setModalVehicles([]); ? fetchWeeklyDetail(request.type, request.filters).then(rows=>{if(!cancelled)setModalWeeklyDetail(rows);})
fetchWeeklyDetail(request.type, request.filters) : fetchVehicleList(request.params).then(rows=>{if(!cancelled)setModalVehicles(rows);});
.then(setModalWeeklyDetail) load.catch(()=>{if(!cancelled)setModalError('车辆明细加载失败,请重试。');})
.catch(() => setModalWeeklyDetail([])) .finally(()=>{if(!cancelled)setModalLoading(false);});
.finally(() => setModalLoading(false)); return ()=>{cancelled=true;};
return; }, [showPlateNumbers, selectedSubject, modalRetry]);
}
setModalWeeklyDetail([]);
fetchVehicleList(request.params)
.then(setModalVehicles)
.catch(() => setModalVehicles([]))
.finally(() => setModalLoading(false));
}, [showPlateNumbers, selectedSubject]);
const allTypesExpanded = processedData.length > 0 && processedData.every((t) => expandedAssetTypes.has(t.type)); const allTypesExpanded = processedData.length > 0 && processedData.every((t) => expandedAssetTypes.has(t.type));
@@ -473,7 +468,7 @@ export default function AssetsModule() {
const pendingRate = SUMMARY.totalAssets > 0 ? SUMMARY.pendingDelivery / SUMMARY.totalAssets * 100 : 0; const pendingRate = SUMMARY.totalAssets > 0 ? SUMMARY.pendingDelivery / SUMMARY.totalAssets * 100 : 0;
return ( return (
<div className="min-h-screen bg-[var(--app-bg)] text-gray-800 font-sans p-3 md:p-6 relative"> <div className="asset-workspace min-h-screen bg-[var(--app-bg)] text-gray-800 font-sans p-3 md:p-6 relative">
<AssetsHeader <AssetsHeader
activeTab={activeTab} activeTab={activeTab}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
@@ -481,6 +476,7 @@ export default function AssetsModule() {
setTheme={setTheme} setTheme={setTheme}
lastUpdate={lastUpdate} lastUpdate={lastUpdate}
loading={loading} loading={loading}
onRefresh={loadData}
selectedSubject={selectedSubject} selectedSubject={selectedSubject}
setSelectedSubject={setSelectedSubject} setSelectedSubject={setSelectedSubject}
subjects={subjects} subjects={subjects}
@@ -491,6 +487,8 @@ export default function AssetsModule() {
subjectDropdownRef={subjectDropdownRef} subjectDropdownRef={subjectDropdownRef}
/> />
{error && summary && <div role="alert" className="mb-4 flex items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"><span>更新失败,当前显示上次成功加载的数据。</span><button type="button" onClick={loadData} className="shrink-0 rounded-lg px-3 py-2 font-semibold underline">重试</button></div>}
{activeTab !== 'overview' && <p className="mb-4 text-xs text-slate-500">{activeTab==='department'?'按部门与负责人查看运营车辆,点击数量查看明细。':activeTab==='region'?'查看各区域的车辆分布,展开区域继续查看城市与车型。':'按客户查看运营车辆,客户名称已脱敏;点击合计查看车辆明细。'}</p>}
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -1135,12 +1133,12 @@ export default function AssetsModule() {
<AnimatePresence> <AnimatePresence>
{isRegionFilterOpen && ( {isRegionFilterOpen && (
<> <>
<div className="fixed inset-0 z-40" /> <button type="button" aria-label="关闭筛选" className="fixed inset-0 z-40 bg-slate-900/30" onClick={()=>setIsRegionFilterOpen(false)} />
<motion.div <motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }} initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }} exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-gray-100 z-50 p-4 text-gray-800" className="asset-filter-panel fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-gray-100 z-50 p-4 text-gray-800"
> >
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-bold text-gray-900">区域筛选</h3> <h3 className="text-sm font-bold text-gray-900">区域筛选</h3>
@@ -1346,7 +1344,7 @@ export default function AssetsModule() {
{/* Customer Operations Statistics Section */} {/* Customer Operations Statistics Section */}
<section className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden mb-6 min-h-[420px]"> <section className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden mb-6 min-h-[420px]">
<div className="p-3 sm:p-4 border-b border-gray-50 flex items-center justify-between bg-emerald-800 text-white relative"> <div className="p-3 sm:p-4 border-b border-gray-50 flex items-center justify-between bg-blue-50 text-slate-800 relative">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-1.5 h-6 bg-emerald-400 rounded-full"></div> <div className="w-1.5 h-6 bg-emerald-400 rounded-full"></div>
<div> <div>
@@ -1361,7 +1359,7 @@ export default function AssetsModule() {
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
isCustomerFilterOpen || Object.values(customerFilters).some(v => Array.isArray(v) ? v.length > 0 : v !== '') isCustomerFilterOpen || Object.values(customerFilters).some(v => Array.isArray(v) ? v.length > 0 : v !== '')
? 'bg-emerald-400 text-emerald-900 shadow-lg shadow-emerald-900/20' ? 'bg-emerald-400 text-emerald-900 shadow-lg shadow-emerald-900/20'
: 'bg-emerald-700/50 text-emerald-100 hover:bg-emerald-700' : 'bg-white text-blue-700 hover:bg-blue-100'
}`} }`}
> >
<Filter size={14} /> <Filter size={14} />
@@ -1375,14 +1373,14 @@ export default function AssetsModule() {
{isCustomerFilterOpen && ( {isCustomerFilterOpen && (
<> <>
{/* Backdrop */} {/* Backdrop */}
<div className="fixed inset-0 z-40" /> <button type="button" aria-label="关闭筛选" className="fixed inset-0 z-40 bg-slate-900/30" onClick={()=>setIsCustomerFilterOpen(false)} />
{/* Popover Content */} {/* Popover Content */}
<motion.div <motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }} initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }} exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-gray-100 z-50 p-4 text-gray-800" className="asset-filter-panel fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-gray-100 z-50 p-4 text-gray-800"
> >
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-bold text-gray-900">数据筛选</h3> <h3 className="text-sm font-bold text-gray-900">数据筛选</h3>
@@ -1485,7 +1483,7 @@ export default function AssetsModule() {
<div className="hidden lg:block overflow-x-auto"> <div className="hidden lg:block overflow-x-auto">
<table className="w-full text-left border-collapse min-w-[1000px]"> <table className="w-full text-left border-collapse min-w-[1000px]">
<thead> <thead>
<tr className="bg-emerald-700 text-white text-[11px] uppercase tracking-wider border-b border-emerald-800"> <tr className="bg-slate-100 text-slate-600 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-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 w-24">关联业务负责人</th>
@@ -1691,6 +1689,8 @@ export default function AssetsModule() {
brands={uniqueModalBrands} brands={uniqueModalBrands}
locations={uniqueModalLocations} locations={uniqueModalLocations}
loading={modalLoading} loading={modalLoading}
error={modalError}
onRetry={()=>setModalRetry(v=>v+1)}
weeklyDetails={modalWeeklyDetail} weeklyDetails={modalWeeklyDetail}
filteredWeeklyDetails={filteredModalWeeklyDetail} filteredWeeklyDetails={filteredModalWeeklyDetail}
filteredVehicles={filteredModalVehicles} filteredVehicles={filteredModalVehicles}
+25
View File
@@ -0,0 +1,25 @@
/* Scoped readability and interaction rules for the asset workspace. */
.asset-workspace { font-variant-numeric: tabular-nums; }
.asset-workspace :is(button, input, select, [tabindex]):focus-visible {
outline: 2px solid #2563eb;
outline-offset: 3px;
}
.asset-workspace :is(input, select) { min-height: 38px; }
.asset-workspace [class~="text-[8px]"],
.asset-workspace [class~="text-[9px]"],
.asset-workspace [class~="text-[10px]"] { font-size: 11px; line-height: 1.5; }
.asset-workspace :is(.text-gray-400, .text-slate-400) { color: #64748b; }
.asset-workspace table { font-variant-numeric: tabular-nums; }
.asset-workspace table th { white-space: nowrap; }
.asset-workspace table td { line-height: 1.6; }
.asset-workspace .asset-table-scroll { max-height: 65vh; overflow: auto; overscroll-behavior: contain; }
.asset-workspace .asset-table-scroll thead { position: sticky; top: 0; z-index: 10; background: #f8fafc; }
@media (max-width: 767px) {
.asset-workspace { padding-bottom: 28px; }
.asset-workspace :is(input,select) { min-height: 42px; font-size: 16px; }
.asset-workspace .asset-header { padding-top: 12px; }
.asset-workspace .asset-filter-panel { position: fixed; inset: auto 8px 12px; max-height: 80dvh; overflow-y: auto; width: auto; border-radius: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.asset-workspace * { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
@@ -0,0 +1,52 @@
import Blur from '../../../components/Blur';
import type { VehicleListItem } from '../types';
type DetailsProps = { vehicles: VehicleListItem[] };
function InventoryVehicleDetails({ vehicles, kind }: DetailsProps & { kind: 'abnormal' | 'pending' }) {
const pending = kind === 'pending';
const labels = pending ? ['合同编号', '客户名称(已脱敏)'] : ['异动类型', '预计结束时间'];
const details = (vehicle: VehicleListItem) => pending
? [vehicle.pendingDelivery?.contractNo || '未提供', vehicle.pendingDelivery?.customerName || '未提供']
: [vehicle.abnormalMove?.type || '未提供', vehicle.abnormalMove?.targetCompletionTime || '未提供'];
if (vehicles.length === 0) return <div className="py-14 text-center text-sm text-slate-500">暂无符合筛选条件的{pending ? '待交车' : '异动车辆'}</div>;
return <>
<p className="px-4 py-3 text-xs leading-5 text-slate-500">{pending ? '展示当前待交车任务的合同信息,客户名称已脱敏。' : '展示当前异动类型与预计结束时间。'}</p>
<div className="space-y-3 px-3 pb-3 md:hidden">
{vehicles.map(vehicle => <article key={vehicle.id} className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<h4 className="break-all font-mono text-base font-bold text-blue-700"><Blur>{vehicle.plateNumber || vehicle.vin || '—'}</Blur></h4>
<p className="mt-1 text-xs text-slate-500">{vehicle.brandLabel || '—'} · {vehicle.type} · {vehicle.location === '其他' ? '对接中' : vehicle.location}</p>
<dl className="mt-4 space-y-3 text-xs">
{details(vehicle).map((value, index) => <div key={labels[index]} className="grid grid-cols-[7.5rem_minmax(0,1fr)] gap-2">
<dt className="text-slate-500">{labels[index]}</dt>
<dd className="break-words font-medium text-slate-800 [overflow-wrap:anywhere]"><Blur>{value}</Blur></dd>
</div>)}
</dl>
</article>)}
</div>
<div className="hidden md:block">
<table className="w-full min-w-[700px] border-collapse text-left text-xs">
<thead className="sticky top-0 z-20 bg-slate-700 text-white"><tr>
{['车牌', '品牌', '车型', '所在地', ...labels].map(label => <th key={label} className="whitespace-nowrap p-3">{label}</th>)}
</tr></thead>
<tbody>{vehicles.map(vehicle => <tr key={vehicle.id} className="border-b border-slate-200 bg-white align-top">
<td className="p-3 font-mono font-bold text-blue-700"><Blur>{vehicle.plateNumber || vehicle.vin || '—'}</Blur></td>
<td className="p-3 text-slate-600">{vehicle.brandLabel || '—'}</td>
<td className="p-3 text-slate-600">{vehicle.type}</td>
<td className="p-3 text-slate-600">{vehicle.location === '其他' ? '对接中' : vehicle.location}</td>
{details(vehicle).map((value, index) => <td key={labels[index]} className="max-w-64 break-words p-3 text-slate-700 [overflow-wrap:anywhere]"><Blur>{value}</Blur></td>)}
</tr>)}</tbody>
</table>
</div>
</>;
}
export function AbnormalVehicleDetails(props: DetailsProps) {
return <InventoryVehicleDetails {...props} kind="abnormal" />;
}
export function PendingVehicleDetails(props: DetailsProps) {
return <InventoryVehicleDetails {...props} kind="pending" />;
}
+38 -200
View File
@@ -1,11 +1,9 @@
import React from 'react'; import React from 'react';
import { ChevronDown, Filter, Loader2, Search } from 'lucide-react'; import { Building2, ChevronDown, RefreshCw, Search, X } from 'lucide-react';
import { motion } from 'motion/react';
import type { SubjectOption } from '../api'; import type { SubjectOption } from '../api';
export type AssetsTab = 'overview' | 'department' | 'region' | 'customer'; export type AssetsTab = 'overview' | 'department' | 'region' | 'customer';
export type AssetsTheme = 'soft' | 'minimal' | 'vibrant'; export type AssetsTheme = 'soft' | 'minimal' | 'vibrant';
interface AssetsHeaderProps { interface AssetsHeaderProps {
activeTab: AssetsTab; activeTab: AssetsTab;
setActiveTab: React.Dispatch<React.SetStateAction<AssetsTab>>; setActiveTab: React.Dispatch<React.SetStateAction<AssetsTab>>;
@@ -13,6 +11,7 @@ interface AssetsHeaderProps {
setTheme: React.Dispatch<React.SetStateAction<AssetsTheme>>; setTheme: React.Dispatch<React.SetStateAction<AssetsTheme>>;
lastUpdate: string; lastUpdate: string;
loading: boolean; loading: boolean;
onRefresh: () => void;
selectedSubject: string | null; selectedSubject: string | null;
setSelectedSubject: React.Dispatch<React.SetStateAction<string | null>>; setSelectedSubject: React.Dispatch<React.SetStateAction<string | null>>;
subjects: SubjectOption[]; subjects: SubjectOption[];
@@ -22,204 +21,43 @@ interface AssetsHeaderProps {
setSubjectSearch: React.Dispatch<React.SetStateAction<string>>; setSubjectSearch: React.Dispatch<React.SetStateAction<string>>;
subjectDropdownRef: React.RefObject<HTMLDivElement | null>; subjectDropdownRef: React.RefObject<HTMLDivElement | null>;
} }
// --- Constants --- const tabs: {id: AssetsTab; label: string}[] = [
const TABS = [ {id:'overview',label:'总览'}, {id:'department',label:'按部门'},
{ id: 'overview', label: '总览' }, {id:'region',label:'按区域'}, {id:'customer',label:'按客户'},
{ id: 'department', label: '按部门' },
{ id: 'region', label: '按区域' },
{ id: 'customer', label: '按客户' },
]; ];
export function AssetsHeader({ export function AssetsHeader(props: AssetsHeaderProps) {
activeTab, const {activeTab,setActiveTab,selectedSubject,setSelectedSubject,subjectDropdownOpen,setSubjectDropdownOpen} = props;
setActiveTab, const chooseSubject = (subject: string | null) => {setSelectedSubject(subject);setSubjectDropdownOpen(false);};
theme, return <header className="asset-header sticky top-0 z-40 -mx-3 -mt-3 mb-5 border-b border-slate-200 bg-white/95 px-4 pt-4 backdrop-blur-md md:-mx-6 md:-mt-6 md:px-6">
setTheme, <div className="flex items-center justify-between gap-3">
lastUpdate, <div><h1 className="text-xl font-bold tracking-tight text-slate-900">资产管理</h1><p className="mt-1 hidden text-xs text-slate-500 sm:block">运营、库存与交还车,一处查看</p></div>
loading, <div className="flex items-center gap-2">
selectedSubject, <select aria-label="界面风格" value={props.theme} onChange={e=>props.setTheme(e.target.value as AssetsTheme)} className="hidden rounded-lg border border-slate-200 bg-white px-2 py-2 text-xs text-slate-600 md:block">
setSelectedSubject, <option value="soft">柔和</option><option value="minimal">简约</option><option value="vibrant">经典</option>
subjects, </select>
subjectDropdownOpen, <button type="button" onClick={props.onRefresh} disabled={props.loading} className="flex min-h-10 items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 text-xs font-semibold text-slate-700 hover:bg-slate-50 disabled:opacity-60"><RefreshCw size={15} className={props.loading?'animate-spin':''}/>{props.loading?'更新中':'刷新'}</button>
setSubjectDropdownOpen,
subjectSearch,
setSubjectSearch,
subjectDropdownRef,
}: AssetsHeaderProps) {
return (
<>
{/* Compact Header Bar */}
<div className="sticky top-0 z-40 -mx-3 -mt-3 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80 md:-mx-6 md:-mt-6">
{/* Title row */}
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">羚牛氢能-资产BI</h1>
{/* Right: status + theme */}
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse inline-block" />
<span>{lastUpdate}</span>
</div>
{loading && (
<div className="flex items-center gap-1 text-[10px] text-gray-400">
<Loader2 className="animate-spin" size={10} />
</div>
)}
<div className="hidden sm:flex bg-gray-100 p-0.5 rounded-lg text-[10px]">
{(['soft','minimal','vibrant'] as const).map((t) => (
<button
key={t}
onClick={() => setTheme(t)}
className={`px-2 py-0.5 rounded-md transition-all ${theme === t ? 'bg-white text-blue-600 shadow-sm font-semibold' : 'text-gray-400 hover:text-gray-600'}`}
>
{t === 'soft' ? '柔和' : t === 'minimal' ? '简约' : '经典'}
</button>
))}
</div>
</div>
</div>
{/* 归属公司作用域筛选 (Scope Chip) */}
<div className="flex items-center justify-center px-4 pt-1">
<div className="relative" ref={subjectDropdownRef}>
<button
type="button"
onClick={() => {
setSubjectDropdownOpen((o) => !o);
setSubjectSearch('');
}}
className={`group inline-flex items-center gap-1.5 h-7 pl-2.5 pr-2 rounded-full border text-[11px] font-normal transition-all cursor-pointer ${
selectedSubject
? 'bg-blue-50 border-blue-300 text-blue-700 hover:bg-blue-100'
: 'bg-white border-gray-200 text-gray-500 hover:border-gray-300 hover:text-gray-700'
}`}
title={selectedSubject || '全部公司'}
>
<Filter size={11} className={selectedSubject ? 'text-blue-500' : 'text-gray-400'} />
<span className="max-w-[180px] truncate">
所属公司:{selectedSubject || '全部公司'}
</span>
{selectedSubject ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
setSelectedSubject(null);
}}
className="ml-0.5 w-3.5 h-3.5 inline-flex items-center justify-center rounded-full text-blue-500 hover:bg-blue-200 hover:text-blue-700 cursor-pointer"
aria-label="清除归属公司筛选"
>
×
</span>
) : (
<ChevronDown size={11} className="text-gray-400" />
)}
</button>
{subjectDropdownOpen && (
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1.5 w-[320px] max-h-[380px] bg-white border border-gray-200 rounded-lg shadow-lg z-50 flex flex-col">
<div className="p-2 border-b border-gray-100">
<div className="relative">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
<input
autoFocus
value={subjectSearch}
onChange={(e) => setSubjectSearch(e.target.value)}
placeholder="搜索公司名"
className="w-full h-7 pl-6 pr-2 text-[11px] bg-gray-50 border border-gray-100 rounded focus:outline-none focus:border-blue-300 focus:bg-white"
/>
</div>
</div>
<div className="overflow-y-auto flex-1 py-1">
<button
type="button"
onClick={() => {
setSelectedSubject(null);
setSubjectDropdownOpen(false);
}}
className={`w-full flex items-center justify-between px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
!selectedSubject ? 'text-blue-600 font-medium' : 'text-gray-700'
}`}
>
<span className="flex items-center gap-1.5">
<span className={`w-1.5 h-1.5 rounded-full ${!selectedSubject ? 'bg-blue-500' : 'bg-gray-300'}`} />
全部公司
</span>
<span className="text-[10px] text-gray-400">
{subjects.reduce((s, x) => s + x.total, 0)} 台
</span>
</button>
<div className="my-1 mx-3 border-t border-gray-100" />
{subjects
.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase()))
.map((s) => {
const active = selectedSubject === s.name;
return (
<button
key={s.name}
type="button"
onClick={() => {
setSelectedSubject(s.name);
setSubjectDropdownOpen(false);
}}
className={`w-full flex items-center justify-between gap-2 px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
active ? 'text-blue-600 font-medium bg-blue-50/40' : 'text-gray-700'
}`}
title={s.name}
>
<span className="flex items-center gap-1.5 min-w-0">
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${active ? 'bg-blue-500' : 'bg-gray-300'}`} />
<span className="truncate">{s.name}</span>
</span>
<span className="text-[10px] text-gray-400 flex-shrink-0 tabular-nums">
{s.total} 台
<span className="mx-1 text-gray-200">·</span>
<span className="text-green-500">运营 {s.operating}</span>
</span>
</button>
);
})}
{subjects.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())).length === 0 && (
<div className="px-3 py-6 text-center text-[11px] text-gray-400">未找到匹配公司</div>
)}
</div>
</div>
)}
</div>
</div>
{/* Tab row */}
<div className="flex items-center justify-center gap-1 px-4 pb-0 overflow-x-auto no-scrollbar">
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`relative px-4 py-2 text-[13px] font-normal transition-all whitespace-nowrap ${
activeTab === tab.id
? 'text-blue-600 font-medium'
: 'text-gray-400 hover:text-gray-500'
}`}
>
{tab.label}
{activeTab === tab.id && (
<motion.div
layoutId="activeTab"
className="absolute bottom-0 left-2 right-2 h-[1.5px] bg-blue-600 rounded-full"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
)}
</button>
))}
</div>
{/* Status row */}
<div className="flex flex-wrap items-center justify-center gap-x-1 gap-y-0.5 px-3 py-1.5 text-[10px] text-gray-400">
<div className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
数据更新时间:{lastUpdate.slice(0, 16)}
</div>
<span aria-hidden="true">|</span>
<span className="whitespace-nowrap">运维台账每日统计口径:当日17:00</span>
</div>
</div> </div>
</div>
</> <div className="mt-3 flex flex-wrap items-center justify-between gap-2">
); <div className="relative flex min-w-0 items-center gap-1" ref={props.subjectDropdownRef}>
<button type="button" aria-expanded={subjectDropdownOpen} aria-controls="asset-company-options" onClick={()=>{setSubjectDropdownOpen(v=>!v);props.setSubjectSearch('');}} className="flex min-h-10 max-w-full items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 px-3 text-xs text-slate-700">
<Building2 size={15}/><span className="max-w-[220px] truncate">{selectedSubject || '全部公司'}</span><ChevronDown size={14}/>
</button>
{selectedSubject && <button type="button" aria-label="清除公司筛选" onClick={()=>chooseSubject(null)} className="rounded-lg p-2 text-slate-500 hover:bg-slate-100"><X size={16}/></button>}
{subjectDropdownOpen && <div id="asset-company-options" className="absolute left-0 top-full z-50 mt-2 w-[min(360px,calc(100vw-48px))] rounded-xl border border-slate-200 bg-white p-2 shadow-xl" onKeyDown={e=>{if(e.key==='Escape'){setSubjectDropdownOpen(false);e.stopPropagation();}}}>
<label className="flex items-center gap-2 rounded-lg border border-slate-200 px-3"><Search size={15} className="text-slate-500"/><input autoFocus aria-label="搜索公司" value={props.subjectSearch} onChange={e=>props.setSubjectSearch(e.target.value)} placeholder="搜索公司名称" className="min-h-10 min-w-0 flex-1 text-sm outline-none"/></label>
<div className="mt-2 max-h-64 overflow-auto">
<button type="button" onClick={()=>chooseSubject(null)} className="w-full rounded-lg px-3 py-3 text-left text-sm hover:bg-blue-50">全部公司</button>
{props.subjects.filter(s=>s.name.includes(props.subjectSearch)).map(s=><button key={s.name} type="button" onClick={()=>chooseSubject(s.name)} className={`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-3 text-left text-xs hover:bg-blue-50 ${selectedSubject===s.name?'bg-blue-50 text-blue-700':'text-slate-700'}`}><span className="min-w-0 break-words">{s.name}</span><span className="shrink-0 tabular-nums text-slate-500">{s.total} 台</span></button>)}
{props.subjects.filter(s=>s.name.includes(props.subjectSearch)).length===0 && <p className="p-4 text-center text-xs text-slate-500">没有匹配的公司</p>}
</div>
</div>}
</div>
<p className="text-[11px] text-slate-500" title="运维台账每日统计口径:当日17:00">更新于 {props.lastUpdate.slice(11,16)}<span className="hidden sm:inline"> · 运维台账截至当日 17:00</span></p>
</div>
<nav aria-label="资产视图" className="mt-3 grid grid-cols-4 gap-1 border-t border-slate-100 pt-2">
{tabs.map(tab=><button type="button" key={tab.id} aria-pressed={activeTab===tab.id} onClick={()=>setActiveTab(tab.id)} className={`min-h-11 rounded-t-lg border-b-2 px-2 py-3 text-sm transition-colors ${activeTab===tab.id?'border-blue-600 bg-blue-50/70 font-bold text-blue-700':'border-transparent text-slate-500 hover:bg-slate-50 hover:text-slate-800'}`}>{tab.label}</button>)}
</nav>
</header>;
} }
@@ -1,4 +1,5 @@
import React from 'react'; import React, { useRef, useCallback } from 'react';
import { useDialogFocus } from './useDialogFocus';
import { Download, X } from 'lucide-react'; import { Download, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import type { FlowDetailItem, FlowType } from '../api'; import type { FlowDetailItem, FlowType } from '../api';
@@ -22,6 +23,9 @@ export function FlowDetailModal({
setSelectedFlow, setSelectedFlow,
exportFlowDetails, exportFlowDetails,
}: FlowDetailModalProps) { }: FlowDetailModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const close = useCallback(()=>setSelectedFlow(null),[setSelectedFlow]);
useDialogFocus(Boolean(selectedFlow),dialogRef,close);
const dateLabel = selectedFlow const dateLabel = selectedFlow
? ('date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`) ? ('date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`)
: ''; : '';
@@ -32,6 +36,7 @@ export function FlowDetailModal({
{selectedFlow && ( {selectedFlow && (
<div className="fixed inset-0 z-[1000] flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-4"> <div className="fixed inset-0 z-[1000] flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-4">
<motion.div <motion.div
ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="asset-flow-title" tabIndex={-1}
data-testid="asset-flow-detail-modal" data-testid="asset-flow-detail-modal"
initial={{ opacity: 0, y: 28, scale: 0.98 }} initial={{ opacity: 0, y: 28, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
@@ -42,7 +47,7 @@ export function FlowDetailModal({
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400">运维签章时间口径 · 单位:车次</div> <div className="text-[11px] font-black uppercase tracking-wide text-slate-400">运维签章时间口径 · 单位:车次</div>
<h3 className="mt-1 text-lg font-black"> <h3 id="asset-flow-title" className="mt-1 text-lg font-black">
{dateLabel} · {FLOW_META[selectedFlow.type].label}明细 {dateLabel} · {FLOW_META[selectedFlow.type].label}明细
</h3> </h3>
<div className="mt-1 text-[12px] font-bold text-slate-300"> <div className="mt-1 text-[12px] font-bold text-slate-300">
+14 -2
View File
@@ -1,3 +1,4 @@
import { useState } from 'react';
import { AssetSummarySection } from './overview/AssetSummarySection'; import { AssetSummarySection } from './overview/AssetSummarySection';
import { FlowStatisticsCard } from './overview/FlowStatisticsCard'; import { FlowStatisticsCard } from './overview/FlowStatisticsCard';
import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection'; import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection';
@@ -5,8 +6,9 @@ import { SummaryMetrics } from './overview/SummaryMetrics';
import type { OverviewViewProps } from './overview/types'; import type { OverviewViewProps } from './overview/types';
export function OverviewView(props: OverviewViewProps) { export function OverviewView(props: OverviewViewProps) {
const [panel, setPanel] = useState<'inventory' | 'models' | 'flow'>('inventory');
return ( return (
<> <div className="space-y-3">
<SummaryMetrics <SummaryMetrics
summary={props.summary} summary={props.summary}
operatingRate={props.operatingRate} operatingRate={props.operatingRate}
@@ -15,6 +17,11 @@ export function OverviewView(props: OverviewViewProps) {
setVehicleSelection={props.setVehicleSelection} setVehicleSelection={props.setVehicleSelection}
/> />
<nav aria-label="总览统计视图" className="grid grid-cols-3 gap-1 rounded-xl border border-slate-200 bg-slate-100 p-1">
{([{id:'inventory',label:'库存统计'},{id:'models',label:'车型资产'},{id:'flow',label:'交还车统计'}] as const).map(item => <button key={item.id} type="button" aria-pressed={panel===item.id} onClick={()=>setPanel(item.id)} className={`min-h-11 rounded-lg px-2 py-2 text-xs sm:text-sm font-semibold transition-colors ${panel===item.id?'bg-white text-blue-700 shadow-sm':'text-slate-600 hover:bg-white/60'}`}>{item.label}</button>)}
</nav>
<div hidden={panel !== 'flow'}>
<FlowStatisticsCard <FlowStatisticsCard
flowRange={props.flowRange} flowRange={props.flowRange}
setFlowRange={props.setFlowRange} setFlowRange={props.setFlowRange}
@@ -26,6 +33,8 @@ export function OverviewView(props: OverviewViewProps) {
setSelectedFlow={props.setSelectedFlow} setSelectedFlow={props.setSelectedFlow}
/> />
</div>
<div hidden={panel !== 'models'}>
<AssetSummarySection <AssetSummarySection
processedData={props.processedData} processedData={props.processedData}
theme={props.theme} theme={props.theme}
@@ -36,6 +45,8 @@ export function OverviewView(props: OverviewViewProps) {
setVehicleSelection={props.setVehicleSelection} setVehicleSelection={props.setVehicleSelection}
/> />
</div>
<div hidden={panel !== 'inventory'}>
<InventoryStatisticsSection <InventoryStatisticsSection
inventoryFilters={props.inventoryFilters} inventoryFilters={props.inventoryFilters}
setInventoryFilters={props.setInventoryFilters} setInventoryFilters={props.setInventoryFilters}
@@ -59,6 +70,7 @@ export function OverviewView(props: OverviewViewProps) {
toggleInventoryType={props.toggleInventoryType} toggleInventoryType={props.toggleInventoryType}
setVehicleSelection={props.setVehicleSelection} setVehicleSelection={props.setVehicleSelection}
/> />
</> </div>
</div>
); );
} }
@@ -1,4 +1,7 @@
import React from 'react'; import React, { useRef, useCallback } from 'react';
import { useDialogFocus } from './useDialogFocus';
import { VehicleMobileCards } from './VehicleMobileCards';
import { AbnormalVehicleDetails, PendingVehicleDetails } from './AbnormalVehicleDetails';
import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react'; import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import Blur from '../../../components/Blur'; import Blur from '../../../components/Blur';
@@ -19,6 +22,8 @@ interface VehicleDetailModalProps {
brands: string[]; brands: string[];
locations: string[]; locations: string[];
loading: boolean; loading: boolean;
error?: string | null;
onRetry?: () => void;
weeklyDetails: WeeklyDetailItem[]; weeklyDetails: WeeklyDetailItem[];
filteredWeeklyDetails: WeeklyDetailItem[]; filteredWeeklyDetails: WeeklyDetailItem[];
filteredVehicles: VehicleListItem[]; filteredVehicles: VehicleListItem[];
@@ -35,24 +40,30 @@ export function VehicleDetailModal({
brands: uniqueModalBrands, brands: uniqueModalBrands,
locations: uniqueModalLocations, locations: uniqueModalLocations,
loading: modalLoading, loading: modalLoading,
error,
onRetry,
weeklyDetails: modalWeeklyDetail, weeklyDetails: modalWeeklyDetail,
filteredWeeklyDetails: filteredModalWeeklyDetail, filteredWeeklyDetails: filteredModalWeeklyDetail,
filteredVehicles: filteredModalVehicles, filteredVehicles: filteredModalVehicles,
}: VehicleDetailModalProps) { }: VehicleDetailModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => setShowPlateNumbers(null), [setShowPlateNumbers]);
useDialogFocus(Boolean(showPlateNumbers), dialogRef, close);
return ( return (
<> <>
{/* Vehicle Detail Modal */} {/* Vehicle Detail Modal */}
<AnimatePresence> <AnimatePresence>
{showPlateNumbers && ( {showPlateNumbers && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[80] flex items-center justify-center p-4">
<motion.div <motion.div
ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="asset-vehicle-title" tabIndex={-1}
initial={{ scale: 0.9, opacity: 0, y: 20 }} initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }} animate={{ scale: 1, opacity: 1, y: 0 }}
className={`bg-white rounded-xl shadow-2xl w-full max-w-[95vw] ${showPlateNumbers.source === 'customer' ? 'lg:max-w-6xl' : 'lg:max-w-4xl'} overflow-hidden flex flex-col max-h-[85vh] sm:max-h-[90vh] min-h-[40vh]`} className={`bg-white rounded-xl shadow-2xl w-full max-w-[95vw] ${showPlateNumbers.source === 'customer' ? 'lg:max-w-6xl' : 'lg:max-w-4xl'} overflow-hidden flex flex-col max-h-[90dvh] sm:max-h-[90vh] min-h-[40vh]`}
> >
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-slate-800 text-white shrink-0"> <div className="p-4 border-b border-gray-100 flex justify-between items-center bg-slate-800 text-white shrink-0">
<div> <div>
<h3 className="font-bold text-base flex items-center gap-2"> <h3 id="asset-vehicle-title" className="font-bold text-base flex items-center gap-2">
<Truck size={18} className="text-blue-400" /> <Truck size={18} className="text-blue-400" />
{showPlateNumbers.title || ( {showPlateNumbers.title || (
(showPlateNumbers.manager ? `${showPlateNumbers.manager} 的${showPlateNumbers.type || ''}车辆` : (showPlateNumbers.manager ? `${showPlateNumbers.manager} 的${showPlateNumbers.type || ''}车辆` :
@@ -67,11 +78,11 @@ export function VehicleDetailModal({
showPlateNumbers.category === 'Returned' ? '已还车' : showPlateNumbers.category === 'Returned' ? '已还车' :
showPlateNumbers.category === 'Replaced' ? '已替换' : showPlateNumbers.category === 'Replaced' ? '已替换' :
showPlateNumbers.category === 'Abnormal' ? '异动' : showPlateNumbers.category === 'Abnormal' ? '异动' :
showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` : showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location === 'All' ? '总' : showPlateNumbers.location}库存` :
showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'} showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}
</p> </p>
</div> </div>
<button onClick={() => setShowPlateNumbers(null)} className="hover:bg-white/10 p-2 rounded-full transition-colors"> <button aria-label="关闭车辆明细" onClick={close} className="hover:bg-white/10 p-2 rounded-full transition-colors">
<PlusCircle className="rotate-45" size={24} /> <PlusCircle className="rotate-45" size={24} />
</button> </button>
</div> </div>
@@ -90,7 +101,7 @@ export function VehicleDetailModal({
{/* Quick Search always visible when collapsed */} {/* Quick Search always visible when collapsed */}
{!isModalFilterExpanded && ( {!isModalFilterExpanded && (
<div className="w-40 sm:w-64" onClick={(e) => e.stopPropagation()}> <div className="w-40 sm:w-64" onClick={(e) => e.stopPropagation()}>
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="快速搜索车牌..." className="text-[11px] py-1 px-2" /> <SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="搜索车牌 / VIN" className="text-[11px] py-1 px-2" />
</div> </div>
)} )}
<motion.div <motion.div
@@ -151,10 +162,16 @@ export function VehicleDetailModal({
</div> </div>
<div className="flex-1 overflow-auto p-0 sm:p-4 bg-gray-50 min-h-0 overscroll-contain"> <div className="flex-1 overflow-auto p-0 sm:p-4 bg-gray-50 min-h-0 overscroll-contain">
{modalLoading ? ( {error ? (
<div role="alert" className="p-10 text-center text-sm text-slate-600"><p>{error}</p><button type="button" onClick={onRetry} className="mt-4 rounded-xl bg-blue-600 px-5 py-2 text-white">重新加载</button></div>
) : modalLoading ? (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
<Loader2 className="animate-spin text-blue-500" size={32} /> <Loader2 className="animate-spin text-blue-500" size={32} />
</div> </div>
) : showPlateNumbers.category === 'Abnormal' ? (
<AbnormalVehicleDetails vehicles={filteredModalVehicles} />
) : showPlateNumbers.category === 'Pending' ? (
<PendingVehicleDetails vehicles={filteredModalVehicles} />
) : modalWeeklyDetail.length > 0 ? ( ) : modalWeeklyDetail.length > 0 ? (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden w-full"> <div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden w-full">
<table className="w-full text-left border-collapse"> <table className="w-full text-left border-collapse">
@@ -177,7 +194,9 @@ export function VehicleDetailModal({
</table> </table>
</div> </div>
) : ( ) : (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm w-full"> <>
<VehicleMobileCards vehicles={filteredModalVehicles} customer={showPlateNumbers.source === 'customer'} />
<div className="hidden md:block bg-white rounded-lg border border-gray-200 shadow-sm w-full">
<table className="min-w-full w-max text-left border-collapse"> <table className="min-w-full w-max text-left border-collapse">
<thead className="sticky top-0 z-20 shadow-sm"> <thead className="sticky top-0 z-20 shadow-sm">
<tr className="bg-slate-700 text-white text-[10px] uppercase tracking-wider whitespace-nowrap"> <tr className="bg-slate-700 text-white text-[10px] uppercase tracking-wider whitespace-nowrap">
@@ -223,7 +242,7 @@ export function VehicleDetailModal({
<td className="p-2 border-r border-gray-100 font-bold text-gray-800"><Blur>{v.customerName || '—'}</Blur></td> <td className="p-2 border-r border-gray-100 font-bold text-gray-800"><Blur>{v.customerName || '—'}</Blur></td>
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td> <td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 text-center"> <td className="p-2 border-r border-gray-100 text-center">
<span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${ <span className={`px-1.5 py-0.5 rounded-full text-[11px] font-bold ${
v.status === 'Operating' ? 'bg-green-100 text-green-700' : v.status === 'Operating' ? 'bg-green-100 text-green-700' :
v.status === 'Inventory' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700' v.status === 'Inventory' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'
}`}> }`}>
@@ -259,12 +278,13 @@ export function VehicleDetailModal({
</tbody> </tbody>
</table> </table>
</div> </div>
</>
)} )}
</div> </div>
<div className="p-4 bg-white border-t border-gray-100 flex justify-between items-center shrink-0"> <div className="p-4 bg-white border-t border-gray-100 flex justify-between items-center shrink-0">
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
共计 <span className="font-bold text-blue-600">{filteredModalWeeklyDetail.length > 0 ? filteredModalWeeklyDetail.length : filteredModalVehicles.length}</span> 台车辆 共计 <span className="font-bold text-blue-600">{['Delivered','Returned','Replaced'].includes(showPlateNumbers.category || '') ? filteredModalWeeklyDetail.length : filteredModalVehicles.length}</span> 台车辆
</div> </div>
<button <button
onClick={() => setShowPlateNumbers(null)} onClick={() => setShowPlateNumbers(null)}
@@ -0,0 +1,15 @@
import Blur from '../../../components/Blur';
import type { VehicleListItem } from '../types';
export function VehicleMobileCards({vehicles, customer}: {vehicles: VehicleListItem[];customer: boolean}) {
return <div className="space-y-3 p-3 md:hidden">
{vehicles.length===0 && <p className="py-12 text-center text-sm text-slate-500">暂无符合条件的车辆,试试调整筛选条件。</p>}
{vehicles.map(vehicle=><article key={vehicle.id} className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-start justify-between gap-2"><h4 className="break-all font-mono text-base font-bold text-blue-700"><Blur>{vehicle.plateNumber || vehicle.vin || '未提供车牌'}</Blur></h4><span className="shrink-0 rounded-md bg-slate-100 px-2 py-1 text-xs text-slate-600">{({Operating:'运营',Inventory:'库存',Pending:'待交车',Abnormal:'异动'} as Record<string,string>)[vehicle.status] || '未提供'}</span></div>
<p className="mt-2 text-xs text-slate-500">{vehicle.brandLabel || '—'} · {vehicle.type} · {vehicle.location==='其他'?'对接中':vehicle.location}</p>
<dl className="mt-3 space-y-2 border-t border-slate-100 pt-3 text-xs">
{(customer ? [['客户',vehicle.customerName],['合同编号',vehicle.contractNo],['业务部门',vehicle.departmentName],['业务负责人',vehicle.customerManager],['资产归属',vehicle.subjectOrg]] : [['型号',vehicle.model],['客户',vehicle.customerName]]).map(([label,value])=><div key={label} className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-2"><dt className="text-slate-500">{label}</dt><dd className="break-words text-slate-800 [overflow-wrap:anywhere]"><Blur>{value || '未提供'}</Blur></dd></div>)}
</dl>
</article>)}
</div>;
}
@@ -13,7 +13,7 @@ export function AssetSummaryDesktopTable({
setVehicleSelection: setShowPlateNumbers, setVehicleSelection: setShowPlateNumbers,
}: AssetSummarySectionProps) { }: AssetSummarySectionProps) {
return ( return (
<div className="hidden lg:block overflow-x-auto"> <div className="hidden lg:block asset-table-scroll overflow-x-auto">
<table className="w-full text-left border-collapse table-fixed min-w-[1300px]"> <table className="w-full text-left border-collapse table-fixed min-w-[1300px]">
<thead> <thead>
<tr className="bg-gray-50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-100"> <tr className="bg-gray-50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-100">
@@ -26,8 +26,8 @@ export function AssetSummaryDesktopTable({
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-北京</th> <th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-北京</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-新疆</th> <th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-新疆</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-其他</th> <th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-其他</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center text-amber-600 w-24">异动</th> <th className="p-3 font-semibold border-r border-gray-100 text-center text-amber-600 w-24">其中异动</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">待交车</th> <th className="p-3 font-semibold border-r border-gray-100 text-center w-24">其中待交车</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-green-50/30 w-24">当前在运营</th> <th className="p-3 font-semibold border-r border-gray-100 text-center bg-green-50/30 w-24">当前在运营</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/20 w-24">本周交车</th> <th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/20 w-24">本周交车</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-orange-50/20 w-24">本周还车</th> <th className="p-3 font-semibold border-r border-gray-100 text-center bg-orange-50/20 w-24">本周还车</th>
@@ -43,7 +43,7 @@ export function AssetSummaryDesktopTable({
theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 hover:bg-gray-50' : theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 hover:bg-gray-50' :
'bg-blue-50/50 hover:bg-blue-50 transition-colors' 'bg-blue-50/50 hover:bg-blue-50 transition-colors'
}`} }`}
onClick={() => toggleAssetType(typeGroup.type)}> tabIndex={0} onKeyDown={e=>{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleAssetType(typeGroup.type);}}} onClick={() => toggleAssetType(typeGroup.type)}>
<td colSpan={15} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}> <td colSpan={15} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -56,7 +56,7 @@ export function AssetSummaryDesktopTable({
<div className={`flex gap-6 text-[11px] font-normal mr-4 ${theme === 'vibrant' ? 'text-white/80' : 'text-gray-500'}`}> <div className={`flex gap-6 text-[11px] font-normal mr-4 ${theme === 'vibrant' ? 'text-white/80' : 'text-gray-500'}`}>
<span>资产 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span> <span>资产 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
<span>库存 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span> <span>库存 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
<span>异动 <span className="font-bold">{typeGroup.totalAbnormal}</span></span> <span>其中异动 <span className="font-bold">{typeGroup.totalAbnormal}</span></span>
<span>运营 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span> <span>运营 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
</div> </div>
</div> </div>
@@ -70,7 +70,7 @@ export function AssetSummaryDesktopTable({
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer ${expandedModels.has(model.model) ? 'bg-blue-50/10' : ''}`} className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer ${expandedModels.has(model.model) ? 'bg-blue-50/10' : ''}`}
onClick={() => toggleModel(model.model)} tabIndex={0} onKeyDown={e=>{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleModel(model.model);}}} onClick={() => toggleModel(model.model)}
> >
<td className="p-3 border-r border-gray-100 text-gray-300 text-center italic">{typeGroup.type}</td> <td className="p-3 border-r border-gray-100 text-gray-300 text-center italic">{typeGroup.type}</td>
<td className="p-3 border-r border-gray-100 flex items-center justify-between gap-2"> <td className="p-3 border-r border-gray-100 flex items-center justify-between gap-2">
@@ -78,7 +78,7 @@ export function AssetSummaryDesktopTable({
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />} {expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
<span className={expandedModels.has(model.model) ? 'font-bold text-blue-700' : ''}>{model.model}</span> <span className={expandedModels.has(model.model) ? 'font-bold text-blue-700' : ''}>{model.model}</span>
</div> </div>
<div className="flex gap-3 text-[9px] font-normal text-gray-400"> <div className="flex gap-3 text-[11px] font-normal text-gray-400">
<span>资产 <span className="font-bold text-gray-600">{model.total}</span></span> <span>资产 <span className="font-bold text-gray-600">{model.total}</span></span>
<span>库存 <span className="font-bold text-blue-500">{model.inventory}</span></span> <span>库存 <span className="font-bold text-blue-500">{model.inventory}</span></span>
<span>运营 <span className="font-bold text-green-500">{model.operating}</span></span> <span>运营 <span className="font-bold text-green-500">{model.operating}</span></span>
@@ -21,7 +21,7 @@ export function AssetSummaryMobileCards({
theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 text-gray-800 active:bg-gray-50' : theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 text-gray-800 active:bg-gray-50' :
'bg-blue-50 border border-blue-100 text-blue-700 active:bg-blue-100' 'bg-blue-50 border border-blue-100 text-blue-700 active:bg-blue-100'
}`} }`}
onClick={() => toggleAssetType(typeGroup.type)} tabIndex={0} onKeyDown={e=>{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleAssetType(typeGroup.type);}}} onClick={() => toggleAssetType(typeGroup.type)}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{expandedAssetTypes.has(typeGroup.type) ? {expandedAssetTypes.has(typeGroup.type) ?
@@ -30,10 +30,10 @@ export function AssetSummaryMobileCards({
} }
<span className="text-xs font-bold">{typeGroup.type}</span> <span className="text-xs font-bold">{typeGroup.type}</span>
</div> </div>
<div className={`flex gap-3 text-[9px] font-normal ${theme === 'vibrant' ? 'opacity-90' : 'text-gray-500'}`}> <div className={`flex gap-3 text-[11px] font-normal ${theme === 'vibrant' ? 'opacity-90' : 'text-gray-500'}`}>
<span>资产 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span> <span>资产 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
<span>库存 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span> <span>库存 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
<span>异动 <span className="font-bold">{typeGroup.totalAbnormal}</span></span> <span>其中异动 <span className="font-bold">{typeGroup.totalAbnormal}</span></span>
<span>运营 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span> <span>运营 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
</div> </div>
</div> </div>
@@ -50,7 +50,7 @@ export function AssetSummaryMobileCards({
<div key={model.model} className="bg-white rounded-lg border border-gray-100 shadow-sm overflow-hidden"> <div key={model.model} className="bg-white rounded-lg border border-gray-100 shadow-sm overflow-hidden">
<div <div
className="p-3 flex justify-between items-center cursor-pointer active:bg-gray-50" className="p-3 flex justify-between items-center cursor-pointer active:bg-gray-50"
onClick={() => toggleModel(model.model)} tabIndex={0} onKeyDown={e=>{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleModel(model.model);}}} onClick={() => toggleModel(model.model)}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />} {expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
@@ -59,7 +59,7 @@ export function AssetSummaryMobileCards({
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">
<span className="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-blue-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model }); }}>资产 {model.total}</span> <span className="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-blue-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model }); }}>资产 {model.total}</span>
<span className="text-[10px] bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-orange-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` }); }}>库存 {model.inventory}</span> <span className="text-[10px] bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-orange-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` }); }}>库存 {model.inventory}</span>
<button type="button" className="text-[10px] bg-amber-50 text-amber-700 px-1.5 py-0.5 rounded font-bold" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Abnormal', source: 'asset', title: `${model.model} - 异动` }); }}>异动 {model.abnormal}</button> <button type="button" className="text-[10px] bg-amber-50 text-amber-700 px-1.5 py-0.5 rounded font-bold" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Abnormal', source: 'asset', title: `${model.model} - 异动` }); }}>其中异动 {model.abnormal}</button>
<span className="text-[10px] bg-green-50 text-green-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-green-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` }); }}>运营 {model.operating}</span> <span className="text-[10px] bg-green-50 text-green-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-green-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` }); }}>运营 {model.operating}</span>
</div> </div>
</div> </div>
@@ -74,13 +74,13 @@ export function AssetSummaryMobileCards({
</div> </div>
<div className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors" <div className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` })}> onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` })}>
<span className="text-[10px] text-gray-400">待交车</span> <span className="text-[10px] text-gray-400">其中待交车</span>
<span className="text-xs font-bold text-gray-600">{model.pending}</span> <span className="text-xs font-bold text-gray-600">{model.pending}</span>
</div> </div>
<div className="col-span-2 grid grid-cols-5 gap-1 py-2 border-y border-gray-100"> <div className="col-span-2 grid grid-cols-5 gap-1 py-2 border-y border-gray-100">
{['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => ( {['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => (
<div key={reg} className="text-center"> <div key={reg} className="text-center">
<div className="text-[8px] text-gray-400 mb-0.5">{reg === '嘉兴' ? '浙' : reg === '广东' ? '粤' : reg === '北京' ? '京' : reg === '新疆' ? '新' : '其'}</div> <div className="text-[11px] text-gray-400 mb-0.5">{reg === '嘉兴' ? '浙' : reg === '广东' ? '粤' : reg === '北京' ? '京' : reg === '新疆' ? '新' : '其'}</div>
{model.inventoryRegions[reg] > 0 ? ( {model.inventoryRegions[reg] > 0 ? (
<button <button
onClick={(e) => { onClick={(e) => {
@@ -100,17 +100,17 @@ export function AssetSummaryMobileCards({
<div className="col-span-2 grid grid-cols-3 gap-2 pt-1"> <div className="col-span-2 grid grid-cols-3 gap-2 pt-1">
<div className="bg-blue-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-blue-100/50 transition-colors" <div className="bg-blue-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-blue-100/50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` })}> onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` })}>
<span className="text-[8px] text-gray-400 mb-1">本周已交车</span> <span className="text-[11px] text-gray-400 mb-1">本周已交车</span>
<span className="text-xs font-bold text-blue-600">{model.weeklyDelivered}</span> <span className="text-xs font-bold text-blue-600">{model.weeklyDelivered}</span>
</div> </div>
<div className="bg-orange-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-orange-100/50 transition-colors" <div className="bg-orange-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-orange-100/50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` })}> onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` })}>
<span className="text-[8px] text-gray-400 mb-1">已还车</span> <span className="text-[11px] text-gray-400 mb-1">已还车</span>
<span className="text-xs font-bold text-orange-600">{model.weeklyReturned}</span> <span className="text-xs font-bold text-orange-600">{model.weeklyReturned}</span>
</div> </div>
<div className="bg-purple-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-purple-100/50 transition-colors" <div className="bg-purple-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-purple-100/50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` })}> onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` })}>
<span className="text-[8px] text-gray-400 mb-1">已替换</span> <span className="text-[11px] text-gray-400 mb-1">已替换</span>
<span className="text-xs font-bold text-purple-600">{model.weeklyReplaced}</span> <span className="text-xs font-bold text-purple-600">{model.weeklyReplaced}</span>
</div> </div>
</div> </div>
@@ -1,3 +1,4 @@
import { InventoryStatusCounts } from './InventoryStatusCounts';
import React from 'react'; import React from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react'; import { ChevronDown, ChevronRight } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
@@ -26,7 +27,7 @@ export function InventoryDesktopTable({
setVehicleSelection: setShowPlateNumbers, setVehicleSelection: setShowPlateNumbers,
}: InventoryDesktopTableProps) { }: InventoryDesktopTableProps) {
return ( return (
<div className="hidden lg:block overflow-x-auto"> <div className="hidden lg:block asset-table-scroll overflow-x-auto">
<table className="w-full text-left border-collapse"> <table className="w-full text-left border-collapse">
<thead> <thead>
<tr className="bg-slate-50 text-[11px] text-slate-500 uppercase tracking-wider border-b border-slate-100"> <tr className="bg-slate-50 text-[11px] text-slate-500 uppercase tracking-wider border-b border-slate-100">
@@ -34,6 +35,8 @@ export function InventoryDesktopTable({
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '品牌' : '品牌'}</th> <th className="p-3 font-semibold">{inventoryTab === 'region' ? '品牌' : '品牌'}</th>
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '车型' : '所在区域/城市'}</th> <th className="p-3 font-semibold">{inventoryTab === 'region' ? '车型' : '所在区域/城市'}</th>
<th className="p-3 font-semibold text-center w-32">库存数量</th> <th className="p-3 font-semibold text-center w-32">库存数量</th>
<th className="p-3 font-semibold text-center text-amber-700 w-28">其中异动</th>
<th className="p-3 font-semibold text-center text-blue-700 w-28">其中待交车</th>
</tr> </tr>
</thead> </thead>
<tbody className="text-xs"> <tbody className="text-xs">
@@ -42,9 +45,9 @@ export function InventoryDesktopTable({
<React.Fragment key={region}> <React.Fragment key={region}>
<tr <tr
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors" className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
onClick={() => toggleInventoryRegion(region)} tabIndex={0} onKeyDown={e=>{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleInventoryRegion(region);}}} onClick={() => toggleInventoryRegion(region)}
> >
<td colSpan={4} className="p-3 font-bold text-slate-700"> <td colSpan={6} className="p-3 font-bold text-slate-700">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />} {expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span>{region}</span> <span>{region}</span>
@@ -55,6 +58,7 @@ export function InventoryDesktopTable({
}}> }}>
(共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台) (共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
</span> </span>
<InventoryStatusCounts rows={Object.values(cities).flat()} />
</div> </div>
</td> </td>
</tr> </tr>
@@ -84,6 +88,8 @@ export function InventoryDesktopTable({
{stat.quantity} {stat.quantity}
</button> </button>
</td> </td>
<td className="p-3 text-center font-bold text-amber-700">{stat.abnormal ?? 0}</td>
<td className="p-3 text-center font-bold text-blue-700">{stat.pending ?? 0}</td>
</motion.tr> </motion.tr>
))} ))}
</React.Fragment> </React.Fragment>
@@ -96,9 +102,9 @@ export function InventoryDesktopTable({
<React.Fragment key={type}> <React.Fragment key={type}>
<tr <tr
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors" className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
onClick={() => toggleInventoryType(type)} tabIndex={0} onKeyDown={e=>{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleInventoryType(type);}}} onClick={() => toggleInventoryType(type)}
> >
<td colSpan={4} className="p-3 font-bold text-slate-700"> <td colSpan={6} className="p-3 font-bold text-slate-700">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />} {expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span>{type}</span> <span>{type}</span>
@@ -109,6 +115,7 @@ export function InventoryDesktopTable({
}}> }}>
(共 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台) (共 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
</span> </span>
<InventoryStatusCounts rows={Object.values(models).flat()} />
</div> </div>
</td> </td>
</tr> </tr>
@@ -138,6 +145,8 @@ export function InventoryDesktopTable({
{stat.quantity} {stat.quantity}
</button> </button>
</td> </td>
<td className="p-3 text-center font-bold text-amber-700">{stat.abnormal ?? 0}</td>
<td className="p-3 text-center font-bold text-blue-700">{stat.pending ?? 0}</td>
</motion.tr> </motion.tr>
))} ))}
</React.Fragment> </React.Fragment>
@@ -1,3 +1,4 @@
import { InventoryStatusCounts } from './InventoryStatusCounts';
import { ChevronDown, ChevronRight } from 'lucide-react'; import { ChevronDown, ChevronRight } from 'lucide-react';
import type { InventoryStatisticsSectionProps } from './types'; import type { InventoryStatisticsSectionProps } from './types';
@@ -29,8 +30,8 @@ export function InventoryMobileList({
Object.entries(inventoryByRegion).map(([region, cities]) => ( Object.entries(inventoryByRegion).map(([region, cities]) => (
<div key={region} className="border border-slate-100 rounded overflow-hidden"> <div key={region} className="border border-slate-100 rounded overflow-hidden">
<div <div
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer" className="bg-slate-50 p-3 flex flex-wrap gap-2 justify-between items-center cursor-pointer"
onClick={() => toggleInventoryRegion(region)} tabIndex={0} onKeyDown={e=>{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleInventoryRegion(region);}}} onClick={() => toggleInventoryRegion(region)}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />} {expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
@@ -39,6 +40,7 @@ export function InventoryMobileList({
<span className="text-[10px] font-bold text-blue-600"> <span className="text-[10px] font-bold text-blue-600">
{Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
</span> </span>
<InventoryStatusCounts rows={Object.values(cities).flat()} />
</div> </div>
{expandedInventoryRegions.has(region) && ( {expandedInventoryRegions.has(region) && (
@@ -48,9 +50,9 @@ export function InventoryMobileList({
<div className="text-[10px] font-bold text-slate-500 mb-2">{city}</div> <div className="text-[10px] font-bold text-slate-500 mb-2">{city}</div>
<div className="space-y-2"> <div className="space-y-2">
{stats.map((stat, idx) => ( {stats.map((stat, idx) => (
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded"> <div key={idx} className="flex flex-wrap gap-2 justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-slate-400 text-[9px]">{stat.brand}</span> <span className="text-slate-400 text-[11px]">{stat.brand}</span>
<span className="text-slate-700 font-medium">{stat.model}</span> <span className="text-slate-700 font-medium">{stat.model}</span>
</div> </div>
<button <button
@@ -59,6 +61,7 @@ export function InventoryMobileList({
> >
{stat.quantity} 台 {stat.quantity} 台
</button> </button>
<div className="w-full border-t border-slate-100 pt-2"><InventoryStatusCounts rows={[stat]} /></div>
</div> </div>
))} ))}
</div> </div>
@@ -72,8 +75,8 @@ export function InventoryMobileList({
Object.entries(inventoryByModel).map(([type, models]) => ( Object.entries(inventoryByModel).map(([type, models]) => (
<div key={type} className="border border-slate-100 rounded overflow-hidden"> <div key={type} className="border border-slate-100 rounded overflow-hidden">
<div <div
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer" className="bg-slate-50 p-3 flex flex-wrap gap-2 justify-between items-center cursor-pointer"
onClick={() => toggleInventoryType(type)} tabIndex={0} onKeyDown={e=>{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleInventoryType(type);}}} onClick={() => toggleInventoryType(type)}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />} {expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
@@ -82,6 +85,7 @@ export function InventoryMobileList({
<span className="text-[10px] font-bold text-blue-600"> <span className="text-[10px] font-bold text-blue-600">
{Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
</span> </span>
<InventoryStatusCounts rows={Object.values(models).flat()} />
</div> </div>
{expandedInventoryTypes.has(type) && ( {expandedInventoryTypes.has(type) && (
@@ -91,9 +95,9 @@ export function InventoryMobileList({
<div className="text-[10px] font-bold text-slate-500 mb-2">{model}</div> <div className="text-[10px] font-bold text-slate-500 mb-2">{model}</div>
<div className="space-y-2"> <div className="space-y-2">
{stats.map((stat, idx) => ( {stats.map((stat, idx) => (
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded"> <div key={idx} className="flex flex-wrap gap-2 justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-slate-400 text-[9px]">{stat.brand}</span> <span className="text-slate-400 text-[11px]">{stat.brand}</span>
<span className="text-slate-700 font-medium">{stat.region} / {stat.city}</span> <span className="text-slate-700 font-medium">{stat.region} / {stat.city}</span>
</div> </div>
<button <button
@@ -102,6 +106,7 @@ export function InventoryMobileList({
> >
{stat.quantity} 台 {stat.quantity} 台
</button> </button>
<div className="w-full border-t border-slate-100 pt-2"><InventoryStatusCounts rows={[stat]} /></div>
</div> </div>
))} ))}
</div> </div>
@@ -1,3 +1,4 @@
import { InventoryStatusCounts } from './InventoryStatusCounts';
import { Filter } from 'lucide-react'; import { Filter } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { InventoryDesktopTable } from './InventoryDesktopTable'; import { InventoryDesktopTable } from './InventoryDesktopTable';
@@ -39,20 +40,19 @@ export function InventoryStatisticsSection({
}; };
return ( return (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm mb-6 overflow-hidden min-h-[420px]"> <div data-testid="inventory-statistics" className="bg-white rounded-2xl border border-gray-100 shadow-sm mb-6 overflow-hidden min-h-[420px]">
<div className="p-3 sm:p-4 border-b border-gray-50 bg-white flex items-center justify-between relative"> <div className="p-3 sm:p-4 border-b border-gray-50 bg-white flex items-center justify-between relative">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-1.5 h-6 bg-blue-600 rounded-full"></div> <div className="w-1.5 h-6 bg-blue-600 rounded-full"></div>
<div className="flex flex-col"> <div className="flex flex-col">
<h2 className="text-lg font-bold text-gray-800 leading-tight">库存统计</h2> <h2 className="text-lg font-bold text-gray-800 leading-tight">库存统计</h2>
<span className="text-[10px] text-gray-400 font-medium">实时更新库存数据</span> <span className="text-[10px] text-gray-400 font-medium">按区域或车型查看库存,点击数量查看车辆</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-6 ml-auto pr-2"> <div className="flex items-center gap-3 ml-auto pr-2">
<div className="flex flex-col items-end cursor-pointer hover:opacity-80 transition-opacity" <div className="flex flex-col items-end">
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}> <span className="text-[10px] text-gray-400 font-bold tracking-wider uppercase mb-0.5">当前库存</span>
<span className="text-[10px] text-gray-400 font-bold tracking-wider uppercase mb-0.5">总库存数据</span>
<span className="text-2xl font-black text-gray-900 leading-none"> <span className="text-2xl font-black text-gray-900 leading-none">
{filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)} {filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)}
<span className="text-xs font-bold text-gray-400 ml-1">台</span> <span className="text-xs font-bold text-gray-400 ml-1">台</span>
@@ -78,14 +78,15 @@ export function InventoryStatisticsSection({
<AnimatePresence> <AnimatePresence>
{isInventoryFilterOpen && ( {isInventoryFilterOpen && (
<> <>
<button type="button" aria-label="关闭库存筛选" className="fixed inset-0 z-40 bg-slate-900/30" onClick={()=>setIsInventoryFilterOpen(false)} />
<motion.div <motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }} initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }} exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-slate-100 z-50 p-4" className="asset-filter-panel fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-slate-100 z-50 p-4"
> >
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h3 className="text-xs font-bold text-slate-800">库存统计 - 数据筛选</h3> <h3 className="text-sm font-bold text-slate-800">筛选库存</h3>
<button onClick={() => setDraftInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[10px] text-blue-500 hover:underline">重置所有</button> <button onClick={() => setDraftInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[10px] text-blue-500 hover:underline">重置所有</button>
</div> </div>
@@ -135,6 +136,11 @@ export function InventoryStatisticsSection({
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-slate-100 px-4 py-3" data-testid="inventory-status-summary">
<InventoryStatusCounts rows={filteredInventoryStats} />
<span className="text-[10px] text-slate-400">随当前筛选统计,已包含在库存总数中</span>
</div>
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100"> <div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
<div className="flex bg-gray-200/50 p-1 rounded-lg w-fit shadow-inner"> <div className="flex bg-gray-200/50 p-1 rounded-lg w-fit shadow-inner">
<button <button
@@ -193,6 +199,7 @@ export function InventoryStatisticsSection({
</div> </div>
)} )}
{filteredInventoryStats.length === 0 && <div className="py-12 text-center"><p className="text-sm font-medium text-slate-700">没有符合条件的库存</p><button type="button" onClick={()=>setInventoryFilters({region:'',city:'',brand:'',type:'',model:''})} className="mt-3 rounded-xl bg-blue-50 px-4 py-2 text-sm text-blue-700">清除筛选</button></div>}
{/* Desktop View Table */} {/* Desktop View Table */}
<InventoryDesktopTable {...listProps} /> <InventoryDesktopTable {...listProps} />
@@ -0,0 +1,11 @@
import type { RegionalInventoryStats } from '../../types';
/** Counts are subsets of inventory and use the same filtered rows as the total. */
export function InventoryStatusCounts({ rows }: { rows: RegionalInventoryStats[] }) {
const abnormal = rows.reduce((sum, row) => sum + (row.abnormal ?? 0), 0);
const pending = rows.reduce((sum, row) => sum + (row.pending ?? 0), 0);
return <span className="inline-flex flex-wrap gap-x-3 gap-y-1 text-[11px] font-normal" data-testid="inventory-status-counts">
<span className="text-amber-700">其中异动 <strong>{abnormal}</strong> 台</span>
<span className="text-blue-700">其中待交车 <strong>{pending}</strong> 台</span>
</span>;
}
@@ -1,105 +1,57 @@
import { Activity, TriangleAlert, PlusCircle, Truck, Warehouse } from 'lucide-react'; import { Activity, TriangleAlert, PlusCircle, Truck, Warehouse, ChevronRight } from 'lucide-react';
import type { SummaryMetricsProps } from './types'; import type { SummaryMetricsProps } from './types';
export function SummaryMetrics({ export function SummaryMetrics({ summary, operatingRate, inventoryRate, setVehicleSelection }: SummaryMetricsProps) {
summary: SUMMARY, const open = (category: 'Operating' | 'Inventory' | 'Abnormal' | 'Pending' | undefined, title: string) =>
operatingRate, setVehicleSelection({ batch: 'All', model: 'All', location: 'All', category, source: 'asset', title });
inventoryRate,
pendingRate,
setVehicleSelection: setShowPlateNumbers,
}: SummaryMetricsProps) {
return (
<>
{/* Header Summary - Ultra Compact */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 mb-2">
{/* Total Assets */}
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
<Truck size={14} />
</div>
<div>
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">资产总数</div>
<div className="text-base font-bold text-gray-800 leading-none">{SUMMARY.totalAssets.toLocaleString()}</div>
</div>
</div>
{/* Operating */} return <div className="mb-3 space-y-2">
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md" <button type="button" onClick={() => open(undefined, '资产概览')}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset', title: '正在运营' })}> className="flex items-center gap-2 px-1 py-2 text-xs text-slate-500 hover:text-blue-600">
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500"> <Truck size={15} /><span>资产总数</span><strong className="text-base text-slate-800">{summary.totalAssets.toLocaleString()}</strong><span>台</span><ChevronRight size={14} />
<Activity size={14} /> </button>
</div> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div> <section data-testid="asset-operating-group" className="overflow-hidden rounded-2xl border border-blue-100 bg-white shadow-sm">
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">总运营</div> <button type="button" onClick={() => open('Operating', '总运营车辆')}
<div className="flex items-baseline gap-1"> className="flex w-full items-center justify-between gap-3 p-4 text-left hover:bg-blue-50/50">
<span className="text-base font-bold text-blue-600 leading-none">{SUMMARY.operating.total}</span> <div className="flex items-center gap-3">
<span className="text-[8px] text-gray-400 leading-none">自{SUMMARY.operating.self} 租{SUMMARY.operating.leased}{SUMMARY.operating.hanging > 0 && ` 挂${SUMMARY.operating.hanging}`}</span> <span className="rounded-xl bg-blue-50 p-2.5 text-blue-600"><Activity size={20} /></span>
</div> <div><h3 className="text-sm font-bold text-slate-800">总运营</h3><p className="mt-1 text-xs text-slate-500">租赁 + 自营</p></div>
</div>
</div>
{/* Inventory */}
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
<Warehouse size={14} />
</div>
<div>
<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-gray-800 leading-none">{SUMMARY.inventory.total}</span>
</div>
</div>
</div>
<button type="button" data-testid="asset-abnormal-card"
className="rounded-2xl border border-amber-100 bg-white p-3 shadow-sm flex items-center gap-2 text-left transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Abnormal', source: 'asset', title: '异动车辆' })}>
<div className="w-8 h-8 rounded-xl bg-amber-50 flex items-center justify-center text-amber-600"><TriangleAlert size={14} /></div>
<div>
<div className="text-[9px] text-amber-600 font-bold leading-none mb-0.5">异动</div>
<div className="text-base font-bold text-amber-600 leading-none">{SUMMARY.inventory.abnormal}</div>
</div> </div>
<div className="flex items-center gap-2"><strong className="text-2xl font-bold text-blue-600">{summary.operating.total}</strong><span className="text-xs text-slate-400">台</span><ChevronRight size={16} className="text-slate-400" /></div>
</button> </button>
<div className="grid grid-cols-2 divide-x divide-blue-100 border-t border-blue-50 bg-blue-50/30 px-4 py-3 text-sm">
{/* Pending */} <div className="flex items-center justify-between gap-2 pr-4"><span className="text-slate-500">租赁</span><strong className="text-slate-800">{summary.operating.leased}</strong></div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md" <div className="flex items-center justify-between gap-2 pl-4"><span className="text-slate-500">自营</span><strong className="text-slate-800">{summary.operating.self}</strong></div>
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}> </div>
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500"> </section>
<PlusCircle size={14} /> <section data-testid="asset-inventory-group" className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<button type="button" onClick={() => open('Inventory', '总库存车辆')}
className="flex w-full items-center justify-between gap-3 p-4 text-left hover:bg-slate-50">
<div className="flex items-center gap-3">
<span className="rounded-xl bg-slate-100 p-2.5 text-slate-600"><Warehouse size={20} /></span>
<div><h3 className="text-sm font-bold text-slate-800">总库存</h3><p className="mt-1 text-xs text-slate-500">待运营 + 可运营</p></div>
</div> </div>
<div> <div className="flex items-center gap-2"><strong className="text-2xl font-bold text-slate-800">{summary.inventory.total}</strong><span className="text-xs text-slate-400">台</span><ChevronRight size={16} className="text-slate-400" /></div>
<div className="text-[9px] text-blue-500 font-bold uppercase leading-none mb-0.5">待交车</div> </button>
<div className="text-base font-bold text-blue-600 leading-none">{SUMMARY.pendingDelivery}</div> <div className="border-t border-slate-100 bg-slate-50/60 px-4 py-3">
<p className="mb-2 text-[11px] text-slate-500">以下车辆已包含在总库存中</p>
<div className="grid grid-cols-2 gap-2">
<button type="button" data-testid="asset-abnormal-card" onClick={() => open('Abnormal', '库存异动车辆')}
className="flex items-center justify-between gap-2 rounded-xl border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-700 hover:bg-amber-100">
<span className="flex items-center gap-1.5"><TriangleAlert size={14} />异动</span><span className="flex items-center gap-1 font-bold">{summary.inventory.abnormal}<ChevronRight size={14} /></span>
</button>
<button type="button" data-testid="asset-pending-card" onClick={() => open('Pending', '库存待交车')}
className="flex items-center justify-between gap-2 rounded-xl border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-700 hover:bg-blue-100">
<span className="flex items-center gap-1.5"><PlusCircle size={14} />待交车</span><span className="flex items-center gap-1 font-bold">{summary.pendingDelivery}<ChevronRight size={14} /></span>
</button>
</div> </div>
</div> </div>
</div> </section>
</div>
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm"> <div data-testid="asset-operation-ratio-strip" className="flex flex-wrap items-center gap-x-5 gap-y-1 px-1 py-1 text-xs text-slate-500">
<div className="flex items-center justify-between gap-3"> <span>运营率 <strong className="text-blue-600">{operatingRate.toFixed(1)}%</strong></span>
<div className="shrink-0 text-[11px] font-black text-slate-400">运营概览</div> <span>库存率 <strong className="text-slate-700">{inventoryRate.toFixed(1)}%</strong></span>
<div className="grid min-w-0 flex-1 grid-cols-4 divide-x divide-slate-100 text-center"> </div>
<div className="px-2"> </div>;
<div className="text-[10px] font-black text-slate-400">运营率</div>
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
</div>
<div className="px-2">
<div className="text-[10px] font-black text-slate-400">库存率</div>
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
</div>
<div className="px-2">
<div className="text-[10px] font-black text-slate-400">异动率</div>
<div className="mt-0.5 text-base font-black text-amber-600">{(SUMMARY.totalAssets > 0 ? SUMMARY.inventory.abnormal / SUMMARY.totalAssets * 100 : 0).toFixed(1)}%</div>
</div>
<div className="px-2">
<div className="text-[10px] font-black text-slate-400">待交率</div>
<div className="mt-0.5 text-base font-black text-amber-600">{pendingRate.toFixed(1)}%</div>
</div>
</div>
</div>
</div>
</>
);
} }
@@ -0,0 +1,22 @@
import { useEffect, type RefObject } from 'react';
export function useDialogFocus(open: boolean, ref: RefObject<HTMLDivElement | null>, close: () => void) {
useEffect(() => {
if (!open) return;
const previous = document.activeElement as HTMLElement | null;
const overflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const frame = requestAnimationFrame(() => ref.current?.focus());
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') { event.preventDefault(); close(); }
if (event.key !== 'Tab') return;
const items = Array.from(ref.current?.querySelectorAll<HTMLElement>('button:not([disabled]), input, select, a[href], [tabindex="0"]') || []).filter(el=>el.getClientRects().length>0);
const first = items[0]; const last = items[items.length-1];
if (!first) { event.preventDefault(); return; }
if (event.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) {event.preventDefault();last.focus();}
else if (!event.shiftKey && (document.activeElement === last || document.activeElement === ref.current)) {event.preventDefault();first.focus();}
};
document.addEventListener('keydown',onKey);
return () => {cancelAnimationFrame(frame);document.removeEventListener('keydown',onKey);document.body.style.overflow=overflow;previous?.focus();};
}, [open, ref, close]);
}
+6
View File
@@ -1,3 +1,5 @@
import type { AbnormalMoveDetail } from '../../shared/assets/abnormal-move';
export interface SummaryData { export interface SummaryData {
totalAssets: number; totalAssets: number;
operating: { operating: {
@@ -101,6 +103,8 @@ export interface InventoryTypeSummary {
} }
export interface VehicleListItem { export interface VehicleListItem {
pendingDelivery?: { contractNo: string | null; customerName: string | null } | null;
abnormalMove?: AbnormalMoveDetail | null;
id: number; id: number;
plateNumber: string; plateNumber: string;
vin: string; vin: string;
@@ -122,6 +126,8 @@ export interface VehicleListItem {
} }
export interface RegionalInventoryStats { export interface RegionalInventoryStats {
abnormal?: number;
pending?: number;
region: string; region: string;
city: string; city: string;
brand: string; brand: string;
+4
View File
@@ -17,6 +17,10 @@ export function maskCustomerNames<T>(items: T[]): T[] {
const copy = { ...obj }; const copy = { ...obj };
if ('customer' in copy && copy.customer) copy.customer = maskCustomerName(copy.customer); if ('customer' in copy && copy.customer) copy.customer = maskCustomerName(copy.customer);
if ('customerName' in copy && copy.customerName) copy.customerName = maskCustomerName(copy.customerName); if ('customerName' in copy && copy.customerName) copy.customerName = maskCustomerName(copy.customerName);
if (copy.pendingDelivery) copy.pendingDelivery = {
...copy.pendingDelivery,
customerName: maskCustomerName(copy.pendingDelivery.customerName),
};
return copy as T; return copy as T;
}); });
} }
+44 -34
View File
@@ -13,6 +13,10 @@ import type { AuthUser } from '../auth/types.js';
import type { Context } from 'hono'; import type { Context } from 'hono';
import { import {
INVENTORY_REGIONS, INVENTORY_REGIONS,
isOperatingVehicle,
isInventoryVehicle,
isInventoryAbnormal,
isInventoryPending,
SUMMARY_TYPE_FILTERS, SUMMARY_TYPE_FILTERS,
VEHICLE_TYPE_FILTERS, VEHICLE_TYPE_FILTERS,
classifyVehicleType, classifyVehicleType,
@@ -63,18 +67,18 @@ app.get('/summary', async (c) => {
const summary: SummaryData = { const summary: SummaryData = {
totalAssets: vehicles.length, totalAssets: vehicles.length,
operating: { operating: {
total: vehicles.filter((v) => v.status === 'Operating' && (v.operationStatus === '1' || v.operationStatus === '2')).length, total: vehicles.filter((v) => isOperatingVehicle(v)).length,
self: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '2').length, self: vehicles.filter((v) => ['2', '自营'].includes(v.operationStatus || '')).length,
leased: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '1').length, leased: vehicles.filter((v) => ['1', '租赁'].includes(v.operationStatus || '')).length,
public: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Public').length, public: vehicles.filter((v) => isOperatingVehicle(v) && v.ownership === 'Public').length,
hanging: 0, hanging: 0,
}, },
inventory: { inventory: {
total: vehicles.filter((v) => v.status === 'Inventory').length, total: vehicles.filter((v) => isInventoryVehicle(v)).length,
inStock: vehicles.filter((v) => v.status === 'Inventory').length, inStock: vehicles.filter((v) => isInventoryVehicle(v) && v.status === 'Inventory').length,
abnormal: vehicles.filter((v) => v.status === 'Abnormal').length, abnormal: vehicles.filter((v) => isInventoryAbnormal(v)).length,
}, },
pendingDelivery: vehicles.filter((v) => v.status === 'Pending').length, pendingDelivery: vehicles.filter((v) => isInventoryPending(v)).length,
weeklyNew: 0, weeklyNew: 0,
weeklyRemoved: 0, weeklyRemoved: 0,
weeklyDelivered: [...weeklyIds.delivered].filter(id => vehicleIds.has(id)).length, weeklyDelivered: [...weeklyIds.delivered].filter(id => vehicleIds.has(id)).length,
@@ -164,7 +168,7 @@ app.get('/inventory-analysis', async (c) => {
const modelData = models.map((model) => { const modelData = models.map((model) => {
const modelVehicles = typeVehicles.filter((v) => v.model === model); const modelVehicles = typeVehicles.filter((v) => v.model === model);
const inventoryVehicles = modelVehicles.filter((v) => v.status === 'Inventory'); const inventoryVehicles = modelVehicles.filter((v) => isInventoryVehicle(v));
return { return {
model, model,
@@ -180,7 +184,7 @@ app.get('/inventory-analysis', async (c) => {
}; };
}); });
const typeInventory = typeVehicles.filter((v) => v.status === 'Inventory'); const typeInventory = typeVehicles.filter((v) => isInventoryVehicle(v));
return { return {
type: t.name, type: t.name,
@@ -203,7 +207,7 @@ app.get('/inventory-analysis', async (c) => {
// GET /api/vehicles/dept-stats — department & manager breakdown with mileage/attendance // GET /api/vehicles/dept-stats — department & manager breakdown with mileage/attendance
app.get('/dept-stats', async (c) => { app.get('/dept-stats', async (c) => {
const vehicles = await getVehiclesForUser(c); const vehicles = await getVehiclesForUser(c);
const withManager = vehicles.filter((v) => v.status === 'Operating'); const withManager = vehicles.filter((v) => isOperatingVehicle(v));
// Query realtime day_mileage from tab_truck_remote_sync_realtime_info // Query realtime day_mileage from tab_truck_remote_sync_realtime_info
const realtimeRows = await vehicleRepository.getRealtimeMileageRows(); const realtimeRows = await vehicleRepository.getRealtimeMileageRows();
@@ -252,7 +256,7 @@ app.get('/dept-stats', async (c) => {
app.get('/region-stats', async (c) => { app.get('/region-stats', async (c) => {
const vehicles = await getVehiclesForUser(c); const vehicles = await getVehiclesForUser(c);
const { customer, city: filterCity, region: filterRegion } = c.req.query(); const { customer, city: filterCity, region: filterRegion } = c.req.query();
let operating = vehicles.filter((v) => v.status === 'Operating' || v.status === 'Pending'); let operating = vehicles.filter((v) => isOperatingVehicle(v) || isInventoryPending(v));
if (customer) operating = operating.filter((v) => v.customerName === customer); if (customer) operating = operating.filter((v) => v.customerName === customer);
if (filterCity) operating = operating.filter((v) => resolveCity(v.city, v.province) === filterCity); if (filterCity) operating = operating.filter((v) => resolveCity(v.city, v.province) === filterCity);
if (filterRegion) operating = operating.filter((v) => mapMacroRegion(v.province, v.city) === filterRegion); if (filterRegion) operating = operating.filter((v) => mapMacroRegion(v.province, v.city) === filterRegion);
@@ -272,10 +276,10 @@ app.get('/region-stats', async (c) => {
const make = (label: string, tv: Vehicle[]) => ({ const make = (label: string, tv: Vehicle[]) => ({
type: label, type: label,
total: tv.length, total: tv.length,
operating: tv.filter((v) => v.status === 'Operating').length, operating: tv.filter((v) => isOperatingVehicle(v)).length,
inventory: tv.filter((v) => v.status === 'Inventory').length, inventory: tv.filter((v) => isInventoryVehicle(v)).length,
abnormal: tv.filter((v) => v.status === 'Abnormal').length, abnormal: tv.filter((v) => isInventoryAbnormal(v)).length,
pending: tv.filter((v) => v.status === 'Pending').length, pending: tv.filter((v) => isInventoryPending(v)).length,
customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[], customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[],
}); });
const known = KNOWN.map((type) => make(type, vList.filter((v) => v.type === type))); const known = KNOWN.map((type) => make(type, vList.filter((v) => v.type === type)));
@@ -295,8 +299,8 @@ app.get('/region-stats', async (c) => {
.map(([city, cv]) => ({ .map(([city, cv]) => ({
city, city,
totalAssets: cv.length, totalAssets: cv.length,
operatingCount: cv.filter((v) => v.status === 'Operating').length, operatingCount: cv.filter((v) => isOperatingVehicle(v)).length,
pendingCount: cv.filter((v) => v.status === 'Pending').length, pendingCount: cv.filter((v) => isInventoryPending(v)).length,
customers: Array.from(new Set(cv.map((v) => v.customerName).filter(Boolean))) as string[], customers: Array.from(new Set(cv.map((v) => v.customerName).filter(Boolean))) as string[],
typeBreakdown: getTypeBreakdown(cv), typeBreakdown: getTypeBreakdown(cv),
})) }))
@@ -324,8 +328,8 @@ app.get('/region-stats', async (c) => {
return { return {
region, region,
totalAssets: allVehicles.length, totalAssets: allVehicles.length,
operatingCount: allVehicles.filter((v) => v.status === 'Operating').length, operatingCount: allVehicles.filter((v) => isOperatingVehicle(v)).length,
pendingCount: allVehicles.filter((v) => v.status === 'Pending').length, pendingCount: allVehicles.filter((v) => isInventoryPending(v)).length,
customers, customers,
typeBreakdown: getTypeBreakdown(allVehicles), typeBreakdown: getTypeBreakdown(allVehicles),
cities, cities,
@@ -338,7 +342,7 @@ app.get('/region-stats', async (c) => {
// GET /api/vehicles/customer-stats — per-customer breakdown for operating vehicles // GET /api/vehicles/customer-stats — per-customer breakdown for operating vehicles
app.get('/customer-stats', async (c) => { app.get('/customer-stats', async (c) => {
const vehicles = await getVehiclesForUser(c); const vehicles = await getVehiclesForUser(c);
const operating = vehicles.filter((v) => v.status === 'Operating'); const operating = vehicles.filter((v) => isOperatingVehicle(v));
const custMap = new Map<string, Vehicle[]>(); const custMap = new Map<string, Vehicle[]>();
for (const v of operating) { for (const v of operating) {
@@ -377,7 +381,7 @@ app.get('/list', async (c) => {
const realtimeRows = await vehicleRepository.getAttendanceMileageRows(); const realtimeRows = await vehicleRepository.getAttendanceMileageRows();
const todayMap = new Map<string, number>(); const todayMap = new Map<string, number>();
for (const row of realtimeRows as any[]) todayMap.set((row.plate_number || '').trim(), Number(row.day_mileage) || 0); for (const row of realtimeRows as any[]) todayMap.set((row.plate_number || '').trim(), Number(row.day_mileage) || 0);
filtered = filtered.filter((v) => v.status === 'Operating'); filtered = filtered.filter((v) => isOperatingVehicle(v));
if (attendance === 'active') { if (attendance === 'active') {
filtered = filtered.filter((v) => (todayMap.get(v.plateNumber) || 0) > 0); filtered = filtered.filter((v) => (todayMap.get(v.plateNumber) || 0) > 0);
} else { } else {
@@ -407,13 +411,13 @@ app.get('/list', async (c) => {
} }
if (category) { if (category) {
if (category === 'Inventory') { if (category === 'Inventory') {
filtered = filtered.filter((v) => v.status === 'Inventory'); filtered = filtered.filter((v) => isInventoryVehicle(v));
} else if (category === 'Abnormal') { } else if (category === 'Abnormal') {
filtered = filtered.filter((v) => v.status === 'Abnormal'); filtered = filtered.filter((v) => isInventoryAbnormal(v));
} else if (category === 'Operating') { } else if (category === 'Operating') {
filtered = filtered.filter((v) => v.status === 'Operating'); filtered = filtered.filter((v) => isOperatingVehicle(v));
} else if (category === 'Pending') { } else if (category === 'Pending') {
filtered = filtered.filter((v) => v.status === 'Pending'); filtered = filtered.filter((v) => isInventoryPending(v));
} }
} }
if (manager) { if (manager) {
@@ -453,6 +457,8 @@ app.get('/list', async (c) => {
rentCompany: v.rentCompany, rentCompany: v.rentCompany,
contractNo: v.contractNo, contractNo: v.contractNo,
customerName: v.customerName, customerName: v.customerName,
abnormalMove: v.abnormalMove ?? null,
pendingDelivery: v.pendingDelivery ?? null,
subjectOrg: v.subjectOrg, subjectOrg: v.subjectOrg,
departmentName: v.departmentName, departmentName: v.departmentName,
customerManager: v.customerManager, customerManager: v.customerManager,
@@ -465,7 +471,7 @@ app.get('/list', async (c) => {
// GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放 // GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放
app.get('/inventory-stats', async (c) => { app.get('/inventory-stats', async (c) => {
const vehicles = applySubjectFilter(c, await vehicleRepository.getVehicles()); const vehicles = applySubjectFilter(c, await vehicleRepository.getVehicles());
const inventory = vehicles.filter((v) => v.status === 'Inventory'); const inventory = vehicles.filter((v) => isInventoryVehicle(v));
const TYPE_NAME_MAP: Record<string, string> = { const TYPE_NAME_MAP: Record<string, string> = {
t4_5: '4.5T普货', t4_5: '4.5T普货',
@@ -476,7 +482,7 @@ app.get('/inventory-stats', async (c) => {
other: '其他', other: '其他',
}; };
const groups = new Map<string, number>(); const groups = new Map<string, { quantity: number; abnormal: number; pending: number }>();
for (const v of inventory) { for (const v of inventory) {
const typeCategory = classifyVehicleType(v); const typeCategory = classifyVehicleType(v);
const typeName = TYPE_NAME_MAP[typeCategory]; const typeName = TYPE_NAME_MAP[typeCategory];
@@ -485,13 +491,17 @@ app.get('/inventory-stats', async (c) => {
const brand = v.brandLabel || '未知'; const brand = v.brandLabel || '未知';
const model = v.model; const model = v.model;
const key = `${region}|${city}|${brand}|${typeName}|${model}`; const key = `${region}|${city}|${brand}|${typeName}|${model}`;
groups.set(key, (groups.get(key) || 0) + 1); const counts = groups.get(key) || { quantity: 0, abnormal: 0, pending: 0 };
counts.quantity += 1;
if (isInventoryAbnormal(v)) counts.abnormal += 1;
if (isInventoryPending(v)) counts.pending += 1;
groups.set(key, counts);
} }
const result = Array.from(groups.entries()) const result = Array.from(groups.entries())
.map(([key, quantity]) => { .map(([key, counts]) => {
const [region, city, brand, type, model] = key.split('|'); const [region, city, brand, type, model] = key.split('|');
return { region, city, brand, type, model, batch: model, quantity }; return { region, city, brand, type, model, batch: model, ...counts };
}) })
.sort((a, b) => b.quantity - a.quantity); .sort((a, b) => b.quantity - a.quantity);
@@ -590,8 +600,8 @@ app.get('/subjects', async (c) => {
if (!map.has(name)) map.set(name, { total: 0, inventory: 0, operating: 0 }); if (!map.has(name)) map.set(name, { total: 0, inventory: 0, operating: 0 });
const s = map.get(name)!; const s = map.get(name)!;
s.total += 1; s.total += 1;
if (v.status === 'Inventory') s.inventory += 1; if (isInventoryVehicle(v)) s.inventory += 1;
if (v.status === 'Operating') s.operating += 1; if (isOperatingVehicle(v)) s.operating += 1;
} }
const result = Array.from(map.entries()) const result = Array.from(map.entries())
@@ -622,7 +632,7 @@ app.get('/debug', async (c) => {
// GET /api/vehicles/region-chart — aggregated chart data with top N + "其他" // GET /api/vehicles/region-chart — aggregated chart data with top N + "其他"
app.get('/region-chart', async (c) => { app.get('/region-chart', async (c) => {
const vehicles = await getVehiclesForUser(c); const vehicles = await getVehiclesForUser(c);
const operating = vehicles.filter((v) => v.status === 'Operating'); const operating = vehicles.filter((v) => isOperatingVehicle(v));
const groupBy = c.req.query('groupBy') || 'region'; // 'region' | 'province' const groupBy = c.req.query('groupBy') || 'region'; // 'region' | 'province'
const source = c.req.query('source') || 'realtime'; // 'realtime' | 'vehicle' const source = c.req.query('source') || 'realtime'; // 'realtime' | 'vehicle'
const top = Number(c.req.query('top')) || 8; const top = Number(c.req.query('top')) || 8;
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { transformRow } from './model.js';
import type { VehicleRow } from '../../types.js';
const row = { id: 'vehicle-1', 车辆租赁状态: '14', 客户经理: '合同经理', 合同归属部门: '合同部门' } as VehicleRow;
test('movement details use source timestamps and preserve work without borrowing lease ownership', () => {
const vehicle = transformRow({ ...row, abnormal_move_id: '1234567890123456789', abnormal_move_type: 1,
abnormal_move_status: 3, abnormal_move_work: '更换制动部件;道路验证',
abnormal_move_target_time: '2026-09-23 18:00', abnormal_move_actual_time: null });
assert.equal(vehicle.abnormalMove?.id, '1234567890123456789');
assert.equal(vehicle.abnormalMove?.type, '维修');
assert.equal(vehicle.abnormalMove?.work, '更换制动部件;道路验证');
assert.equal(vehicle.abnormalMove?.targetCompletionTime, '2026-09-23 18:00');
assert.equal(vehicle.abnormalMove?.actualCompletionTime, null);
for (const key of ['responsiblePerson', 'initiatingDepartment', 'executingDepartment', 'driver'] as const) {
assert.equal(vehicle.abnormalMove?.[key], null);
}
});
test('completed movement retains its actual time and missing records stay distinct', () => {
const completed = transformRow({ ...row, abnormal_move_id: '2', abnormal_move_type: 4,
abnormal_move_status: 5, abnormal_move_work: ' ', abnormal_move_actual_time: '2026-09-22 12:30' });
assert.equal(completed.abnormalMove?.status, '已完成');
assert.equal(completed.abnormalMove?.actualCompletionTime, '2026-09-22 12:30');
assert.equal(completed.abnormalMove?.work, null);
assert.equal(transformRow(row).abnormalMove, null);
});
+56 -8
View File
@@ -33,7 +33,7 @@ test('replacement trips are included in delivery counts without inflating totals
.every((x: { typeLabel: string }) => x.typeLabel === '替换交车')); .every((x: { typeLabel: string }) => x.typeLabel === '替换交车'));
}); });
test('inventory excludes abnormal vehicles in totals, type rollups, regions and drilldowns', async (t) => { test('inventory includes movement and pending subsets consistently in totals, regions and drilldowns', async (t) => {
const vehicles = ['Inventory', 'Abnormal', 'Pending', 'Operating'].map((status, i) => ({ const vehicles = ['Inventory', 'Abnormal', 'Pending', 'Operating'].map((status, i) => ({
id: String(i), status, operationStatus: status === 'Operating' ? '1' : '3', id: String(i), status, operationStatus: status === 'Operating' ? '1' : '3',
plateNumber: `测试${i}`, model: '现代4.5T普货', type: '4.5T', location: '广东', plateNumber: `测试${i}`, model: '现代4.5T普货', type: '4.5T', location: '广东',
@@ -50,18 +50,66 @@ test('inventory excludes abnormal vehicles in totals, type rollups, regions and
}; };
const summary = await get('/summary'); const summary = await get('/summary');
assert.equal(summary.totalAssets, 4); assert.equal(summary.totalAssets, 4);
assert.equal(summary.inventory.total, 1); assert.equal(summary.inventory.total, 3);
assert.equal(summary.inventory.abnormal, 1); assert.equal(summary.inventory.abnormal, 1);
assert.equal(summary.totalAssets, summary.inventory.total + summary.inventory.abnormal + summary.operating.total + summary.pendingDelivery); assert.equal(summary.totalAssets, summary.inventory.total + summary.operating.total);
assert.deepEqual((await get('/list?category=Inventory')).map((v: Vehicle) => v.id), ['0']); assert.deepEqual((await get('/list?category=Inventory')).map((v: Vehicle) => v.id), ['0', '1', '2']);
assert.deepEqual((await get('/list?category=Abnormal')).map((v: Vehicle) => v.id), ['1']); assert.deepEqual((await get('/list?category=Abnormal')).map((v: Vehicle) => v.id), ['1']);
assert.deepEqual(await get('/list?category=Abnormal&subject=甲公司'), []); assert.deepEqual(await get('/list?category=Abnormal&subject=甲公司'), []);
const groups = await get('/by-type'); const groups = await get('/by-type');
assert.equal(groups.reduce((n: number, g: { totalInventory: number }) => n + g.totalInventory, 0), 1); assert.equal(groups.reduce((n: number, g: { totalInventory: number }) => n + g.totalInventory, 0), 3);
assert.equal(groups.reduce((n: number, g: { totalAbnormal: number }) => n + g.totalAbnormal, 0), 1); assert.equal(groups.reduce((n: number, g: { totalAbnormal: number }) => n + g.totalAbnormal, 0), 1);
const model = groups.flatMap((g: { models: unknown[] }) => g.models).find((m: { abnormal: number }) => m.abnormal === 1); const model = groups.flatMap((g: { models: unknown[] }) => g.models).find((m: { abnormal: number }) => m.abnormal === 1);
assert.equal(model.inventory, 1); assert.equal(model.inventory, 3);
assert.equal(model.inventoryRegions['广东'], 1); assert.equal(model.inventoryRegions['广东'], 3);
const inventory = await get('/inventory-stats'); const inventory = await get('/inventory-stats');
assert.equal(inventory.reduce((n: number, row: { quantity: number }) => n + row.quantity, 0), 1); assert.equal(inventory.reduce((n: number, row: { quantity: number }) => n + row.quantity, 0), 3);
assert.equal(inventory.reduce((n: number, row: { abnormal: number }) => n + row.abnormal, 0), 1);
assert.equal(inventory.reduce((n: number, row: { pending: number }) => n + row.pending, 0), 1);
const subjectInventory = await get('/inventory-stats?subject=甲公司');
assert.equal(subjectInventory.reduce((n: number, row: { abnormal: number }) => n + row.abnormal, 0), 0);
assert.equal(subjectInventory.reduce((n: number, row: { pending: number }) => n + row.pending, 0), 1);
});
test('operating category includes every rental/self-operated vehicle regardless of workflow status', async (t) => {
const vehicles = [
{ id: 'rental-movement', operationStatus: '1', status: 'Abnormal' },
{ id: 'self-pending', operationStatus: '2', status: 'Pending' },
{ id: 'available', operationStatus: '3', status: 'Inventory' },
{ id: 'awaiting-operation', operationStatus: '4', status: 'Pending' },
] as Vehicle[];
t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles);
t.mock.method(vehicleRepository, 'getWeeklyTruckIds', async () => ({
pending: new Set<string>(), delivered: new Set<string>(), returned: new Set<string>(), replaced: new Set<string>(),
}));
const summary = await (await router.request('/summary')).json();
assert.equal(summary.operating.total, 2);
assert.equal(summary.operating.self, 1);
assert.equal(summary.operating.leased, 1);
assert.equal(summary.inventory.total, 2);
assert.equal(summary.inventory.abnormal, 0);
assert.equal(summary.pendingDelivery, 1);
const list = async (category: string) => (await (await router.request(`/list?category=${category}`)).json()).map((v: Vehicle) => v.id);
assert.deepEqual(await list('Operating'), ['rental-movement', 'self-pending']);
assert.deepEqual(await list('Inventory'), ['available', 'awaiting-operation']);
assert.deepEqual(await list('Pending'), ['awaiting-operation']);
assert.deepEqual(await list('Abnormal'), []);
});
test('vehicle drilldowns return movement data and mask the current pending customer without mutating cached source', async (t) => {
const pending = { id: 'p', status: 'Pending', operationStatus: '3',
pendingDelivery: { contractNo: 'CONTRACT-2026-01', customerName: '测试客户有限公司' } } as Vehicle;
const movement = { id: 'm', status: 'Abnormal', operationStatus: '4',
abnormalMove: { id: 'move-1', type: '保养', targetCompletionTime: '2026-09-26 18:00' } } as Vehicle;
t.mock.method(vehicleRepository, 'getVehicles', async () => [pending, movement]);
const response = await router.request('/list?category=Pending');
const body = await response.text();
const data = JSON.parse(body);
assert.equal(data[0].pendingDelivery.contractNo, 'CONTRACT-2026-01');
assert.match(data[0].pendingDelivery.customerName, /\*\*\*/);
assert.ok(!body.includes('测试客户有限公司'));
assert.equal(pending.pendingDelivery?.customerName, '测试客户有限公司');
const moves = await (await router.request('/list?category=Abnormal')).json();
assert.equal(moves[0].abnormalMove.type, '保养');
assert.equal(moves[0].abnormalMove.targetCompletionTime, '2026-09-26 18:00');
}); });
+7 -5
View File
@@ -163,6 +163,8 @@ test('transforms database rows without changing the route model contract', () =>
assert.deepEqual(transformRow(row), { assert.deepEqual(transformRow(row), {
id: '88', id: '88',
abnormalMove: null,
pendingDelivery: null,
plateNumber: '粤A00088', plateNumber: '粤A00088',
vin: 'VIN-88', vin: 'VIN-88',
type: '4.5T', type: '4.5T',
@@ -191,9 +193,9 @@ test('transforms database rows without changing the route model contract', () =>
test('preserves inventory, weekly-flow and mileage aggregation boundaries', () => { test('preserves inventory, weekly-flow and mileage aggregation boundaries', () => {
const vehicles = [ const vehicles = [
vehicle({ id: '1', plateNumber: 'A', status: 'Operating', type: '4.5T', model: '普货' }), vehicle({ id: '1', plateNumber: 'A', status: 'Operating', type: '4.5T', model: '普货' }),
vehicle({ id: '2', plateNumber: 'B', status: 'Inventory', location: '嘉兴', type: '4.5T', model: '冷链' }), vehicle({ id: '2', plateNumber: 'B', status: 'Inventory', operationStatus: '3', location: '嘉兴', type: '4.5T', model: '冷链' }),
vehicle({ id: '3', plateNumber: 'C', status: 'Abnormal', location: '北京', type: '挂车', model: '挂车' }), vehicle({ id: '3', plateNumber: 'C', status: 'Abnormal', operationStatus: '4', location: '北京', type: '挂车', model: '挂车' }),
vehicle({ id: '4', plateNumber: 'D', status: 'Pending', type: '叉车', model: '叉车' }), vehicle({ id: '4', plateNumber: 'D', status: 'Pending', operationStatus: '3', type: '叉车', model: '叉车' }),
]; ];
const weeklyIds = { const weeklyIds = {
pending: new Set<string>(), pending: new Set<string>(),
@@ -213,9 +215,9 @@ test('preserves inventory, weekly-flow and mileage aggregation boundaries', () =
}); });
assert.deepEqual(getStats(vehicles, weeklyIds), { assert.deepEqual(getStats(vehicles, weeklyIds), {
total: 4, total: 4,
inventory: 1, inventory: 3,
abnormal: 1, abnormal: 1,
inventoryRegions: { 嘉兴: 1, 广东: 0, 北京: 0, 新疆: 0, 其他: 0 }, inventoryRegions: { 嘉兴: 1, 广东: 1, 北京: 1, 新疆: 0, 其他: 0 },
pending: 1, pending: 1,
operating: 1, operating: 1,
weeklyDelivered: 1, weeklyDelivered: 1,
+35
View File
@@ -64,6 +64,23 @@ export function mapStatus(operationStatus: string | null, vehicleStatus: string
return 'Inventory'; return 'Inventory';
} }
/** Operational category is independent from movement/delivery workflow status. */
export function isOperatingVehicle(vehicle: Vehicle): boolean {
return ['1', '2', '租赁', '自营'].includes((vehicle.operationStatus || '').trim());
}
export function isInventoryVehicle(vehicle: Vehicle): boolean {
return ['3', '4', '可运营', '待运营', '在库'].includes((vehicle.operationStatus || '').trim());
}
export function isInventoryAbnormal(vehicle: Vehicle): boolean {
return isInventoryVehicle(vehicle) && vehicle.status === 'Abnormal';
}
export function isInventoryPending(vehicle: Vehicle): boolean {
return isInventoryVehicle(vehicle) && vehicle.status === 'Pending';
}
export function mapOwnership(rentStatusLabel: string | null): string { export function mapOwnership(rentStatusLabel: string | null): string {
if (!rentStatusLabel) return 'Unknown'; if (!rentStatusLabel) return 'Unknown';
const status = rentStatusLabel.trim(); const status = rentStatusLabel.trim();
@@ -195,6 +212,24 @@ export function transformRow(row: VehicleRow): Vehicle {
const region = mapRegion(row.省, row.市); const region = mapRegion(row.省, row.市);
return { return {
id: row.id, id: row.id,
pendingDelivery: row.pending_delivery_id ? {
contractNo: row.pending_contract_no || null,
customerName: row.pending_customer_name || null,
} : null,
abnormalMove: row.abnormal_move_id ? {
id: row.abnormal_move_id,
type: ({ 1: '维修', 2: '保养', 3: '年审', 4: '其他' } as Record<number, string>)[Number(row.abnormal_move_type)] || '未提供',
status: Number(row.abnormal_move_status) === 5 ? '已完成' : '待结束',
work: row.abnormal_move_work?.trim() || null,
targetCompletionTime: row.abnormal_move_target_time || null,
actualCompletionTime: row.abnormal_move_actual_time || null,
// The source has no confirmed department, assignee or operating-driver fields.
// Do not substitute lease ownership, creator/updater or the vehicle's bound driver.
initiatingDepartment: null,
executingDepartment: null,
responsiblePerson: null,
driver: null,
} : null,
plateNumber: row.车牌号 || '', plateNumber: row.车牌号 || '',
vin: row.vin || '', vin: row.vin || '',
type: deriveType(row.车辆型号Label, row.车辆类型参数), type: deriveType(row.车辆型号Label, row.车辆类型参数),
+33
View File
@@ -37,6 +37,15 @@ interface WeeklyStats {
export const MAIN_SQL = `SELECT export const MAIN_SQL = `SELECT
CAST(vi.id AS CHAR) AS id, CAST(vi.id AS CHAR) AS id,
CAST(am.id AS CHAR) AS abnormal_move_id,
am.move_type AS abnormal_move_type,
CAST(pdv.id AS CHAR) AS pending_delivery_id,
pdts.contract_code AS pending_contract_no,
pdts.customer_name AS pending_customer_name,
am.move_status AS abnormal_move_status,
CONCAT_WS(';', NULLIF(TRIM(am.remark), ''), NULLIF(TRIM(amr.remark), '')) AS abnormal_move_work,
DATE_FORMAT(am.move_plan_end_time, '%Y-%m-%d %H:%i') AS abnormal_move_target_time,
DATE_FORMAT(am.move_end_time, '%Y-%m-%d %H:%i') AS abnormal_move_actual_time,
vi.plate_number AS 车牌号, vi.plate_number AS 车牌号,
vi.vin AS vin, vi.vin AS vin,
vm.brand AS 车辆品牌, vm.brand AS 车辆品牌,
@@ -114,6 +123,30 @@ LEFT JOIN common_district vi_city
LEFT JOIN common_district vi_operation_city LEFT JOIN common_district vi_operation_city
ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci
AND vi_operation_city.STATUS = 'VALID' AND vi_operation_city.STATUS = 'VALID'
-- Pending delivery uses the current active task, never a completed historical lease.
LEFT JOIN delivery_vehicle pdv ON pdv.id = (
SELECT dv.id
FROM delivery_vehicle dv
JOIN delivery_task_subject dts ON dts.id = dv.delivery_task_subject_id
AND dts.del_flag = '0' AND dts.status = 0
WHERE dv.vehicle_id = vi.id AND dv.del_flag = '0' AND dv.status = 0
AND dv.delivery_status IN (0, 1) AND vs.vehicle_status = '4'
ORDER BY dv.create_time DESC, dv.id DESC
LIMIT 1
)
LEFT JOIN delivery_task_subject pdts ON pdts.id = pdv.delivery_task_subject_id
-- Select one effective movement per vehicle; drafts and withdrawals are not operational work.
-- An ongoing movement takes precedence over completed history.
LEFT JOIN vehicle_abnormal_move_record amr ON amr.id = (
SELECT mr.id
FROM vehicle_abnormal_move_record mr
JOIN vehicle_abnormal_move m ON m.id = mr.move_id AND m.del_flag = '0'
WHERE mr.vehicle_id = vi.id AND mr.del_flag = '0' AND m.move_status IN (3, 5)
AND vs.vehicle_status = '14'
ORDER BY (m.move_status = 3) DESC, COALESCE(m.move_start_time, m.create_time) DESC, m.id DESC, mr.id DESC
LIMIT 1
)
LEFT JOIN vehicle_abnormal_move am ON am.id = amr.move_id
${LATEST_VEHICLE_LEASE_JOIN_SQL} ${LATEST_VEHICLE_LEASE_JOIN_SQL}
WHERE vi.del_flag = '0' WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'`; AND COALESCE(vs.operation_status, '') <> '5'`;
+5 -5
View File
@@ -1,5 +1,5 @@
import type { Vehicle } from '../../types.js'; import type { Vehicle } from '../../types.js';
import { REGIONS, classifyVehicleType } from './model.js'; import { REGIONS, classifyVehicleType, isOperatingVehicle, isInventoryVehicle, isInventoryAbnormal, isInventoryPending } from './model.js';
import type { DateRange, VehicleStats, VehicleTypeCounts, WeeklyTruckIds } from './types.js'; import type { DateRange, VehicleStats, VehicleTypeCounts, WeeklyTruckIds } from './types.js';
export function countByType(vehicles: Vehicle[]): VehicleTypeCounts { export function countByType(vehicles: Vehicle[]): VehicleTypeCounts {
@@ -28,14 +28,14 @@ export function getRegionCounts(vehicles: Vehicle[], regions: readonly string[])
export function getStats(list: Vehicle[], weeklyIds?: WeeklyTruckIds): VehicleStats { export function getStats(list: Vehicle[], weeklyIds?: WeeklyTruckIds): VehicleStats {
const ids = list.map((vehicle) => String(vehicle.id)); const ids = list.map((vehicle) => String(vehicle.id));
const inventory = list.filter((vehicle) => vehicle.status === 'Inventory'); const inventory = list.filter(isInventoryVehicle);
return { return {
total: list.length, total: list.length,
inventory: inventory.length, inventory: inventory.length,
abnormal: list.filter((vehicle) => vehicle.status === 'Abnormal').length, abnormal: list.filter(isInventoryAbnormal).length,
inventoryRegions: getRegionCounts(inventory, REGIONS), inventoryRegions: getRegionCounts(inventory, REGIONS),
pending: list.filter((vehicle) => vehicle.status === 'Pending').length, pending: list.filter(isInventoryPending).length,
operating: list.filter((vehicle) => vehicle.status === 'Operating').length, operating: list.filter(isOperatingVehicle).length,
weeklyDelivered: weeklyIds ? ids.filter((id) => weeklyIds.delivered.has(id)).length : 0, weeklyDelivered: weeklyIds ? ids.filter((id) => weeklyIds.delivered.has(id)).length : 0,
weeklyReturned: weeklyIds ? ids.filter((id) => weeklyIds.returned.has(id)).length : 0, weeklyReturned: weeklyIds ? ids.filter((id) => weeklyIds.returned.has(id)).length : 0,
weeklyReplaced: weeklyIds ? ids.filter((id) => weeklyIds.replaced.has(id)).length : 0, weeklyReplaced: weeklyIds ? ids.filter((id) => weeklyIds.replaced.has(id)).length : 0,
+13
View File
@@ -1,4 +1,15 @@
import type { AbnormalMoveDetail } from '../shared/assets/abnormal-move.js';
export interface VehicleRow { export interface VehicleRow {
pending_delivery_id?: string | null;
pending_contract_no?: string | null;
pending_customer_name?: string | null;
abnormal_move_id?: string | null;
abnormal_move_type?: number | null;
abnormal_move_status?: number | null;
abnormal_move_work?: string | null;
abnormal_move_target_time?: string | null;
abnormal_move_actual_time?: string | null;
id: string; id: string;
车牌号: string; 车牌号: string;
vin: string; vin: string;
@@ -30,6 +41,8 @@ export interface VehicleRow {
} }
export interface Vehicle { export interface Vehicle {
pendingDelivery?: { contractNo: string | null; customerName: string | null } | null;
abnormalMove?: AbnormalMoveDetail | null;
id: string; id: string;
plateNumber: string; plateNumber: string;
vin: string; vin: string;
+13
View File
@@ -0,0 +1,13 @@
/** Current or most recent effective movement associated with a vehicle. */
export interface AbnormalMoveDetail {
id: string;
type: string;
work: string | null;
targetCompletionTime: string | null;
actualCompletionTime: string | null;
initiatingDepartment: string | null;
executingDepartment: string | null;
responsiblePerson: string | null;
driver: string | null;
status: string;
}