feat: improve asset inventory statistics and responsive UX (v1.1.16)
This commit is contained in:
Generated
+2
-2
@@ -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
@@ -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\"",
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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" />;
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.车辆类型参数),
|
||||||
|
|||||||
@@ -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'`;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user