diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index 7b9fb8b..fc3a8c7 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -316,17 +316,21 @@ export default function AssetsModule() { }; const toggleInventoryRegion = (region: string) => { - const newSet = new Set(expandedInventoryRegions); - if (newSet.has(region)) newSet.delete(region); - else newSet.add(region); - setExpandedInventoryRegions(newSet); + setExpandedInventoryRegions(current => { + const next = new Set(current); + if (next.has(region)) next.delete(region); + else next.add(region); + return next; + }); }; const toggleInventoryType = (type: string) => { - const newSet = new Set(expandedInventoryTypes); - if (newSet.has(type)) newSet.delete(type); - else newSet.add(type); - setExpandedInventoryTypes(newSet); + setExpandedInventoryTypes(current => { + const next = new Set(current); + if (next.has(type)) next.delete(type); + else next.add(type); + return next; + }); }; const inventoryTypeFilter = isInventoryFilterOpen diff --git a/src/modules/assets/components/AbnormalVehicleDetails.tsx b/src/modules/assets/components/AbnormalVehicleDetails.tsx index 74dc0c7..ed55754 100644 --- a/src/modules/assets/components/AbnormalVehicleDetails.tsx +++ b/src/modules/assets/components/AbnormalVehicleDetails.tsx @@ -5,38 +5,44 @@ type DetailsProps = { vehicles: VehicleListItem[] }; function InventoryVehicleDetails({ vehicles, kind }: DetailsProps & { kind: 'abnormal' | 'pending' }) { const pending = kind === 'pending'; - const labels = pending ? ['合同编号', '客户名称(已脱敏)'] : ['异动类型', '预计结束时间']; + const labels = pending ? ['合同编号', '客户名称(已脱敏)'] : ['异动类型', '具体工作', '申请人', '目标完成时间', '实际完成时间']; const details = (vehicle: VehicleListItem) => pending ? [vehicle.pendingDelivery?.contractNo || '未提供', vehicle.pendingDelivery?.customerName || '未提供'] - : [vehicle.abnormalMove?.type || '未提供', vehicle.abnormalMove?.targetCompletionTime || '未提供']; + : [ + vehicle.abnormalMove?.type || '未提供', + vehicle.abnormalMove?.work || '未提供', + vehicle.abnormalMove?.applicant || '未提供', + vehicle.abnormalMove?.targetCompletionTime || '未提供', + vehicle.abnormalMove?.actualCompletionTime || (vehicle.abnormalMove?.status === '待结束' ? '未完成' : '未提供'), + ]; if (vehicles.length === 0) return
暂无符合筛选条件的{pending ? '待交车' : '异动车辆'}
; return <> -

{pending ? '展示当前待交车任务的合同信息,客户名称已脱敏。' : '展示当前异动类型与预计结束时间。'}

+

{pending ? '展示当前待交车任务的合同信息,客户名称已脱敏。' : '申请人取异动单创建人;未结束的异动显示“未完成”,缺失信息显示“未提供”。'}

{vehicles.map(vehicle =>

{vehicle.plateNumber || vehicle.vin || '—'}

{vehicle.brandLabel || '—'} · {vehicle.type} · {vehicle.location === '其他' ? '对接中' : vehicle.location}

-
- {details(vehicle).map((value, index) =>
+
+ {details(vehicle).map((value, index) =>
{labels[index]}
-
{value}
+
{value}
)}
)}
-
- +
+
- {['车牌', '品牌', '车型', '所在地', ...labels].map(label => )} + {['车牌', '品牌', '车型', '所在地', ...labels].map(label => )} {vehicles.map(vehicle => - {details(vehicle).map((value, index) => )} + {details(vehicle).map((value, index) => )} )}
{label}{label}
{vehicle.plateNumber || vehicle.vin || '—'} {vehicle.brandLabel || '—'} {vehicle.type} {vehicle.location === '其他' ? '对接中' : vehicle.location}{value}{value}
diff --git a/src/modules/assets/components/VehicleDetailModal.tsx b/src/modules/assets/components/VehicleDetailModal.tsx index fb95497..552b157 100644 --- a/src/modules/assets/components/VehicleDetailModal.tsx +++ b/src/modules/assets/components/VehicleDetailModal.tsx @@ -54,7 +54,7 @@ export function VehicleDetailModal({ ref={dialogRef} role="dialog" aria-modal="true" aria-label={showPlateNumbers.title || "车辆明细"} tabIndex={-1} initial={{ scale: 0.9, opacity: 0, y: 20 }} 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' || showPlateNumbers.category === 'Abnormal' ? 'lg:max-w-6xl' : 'lg:max-w-4xl'} overflow-hidden flex flex-col max-h-[85vh] sm:max-h-[90vh] min-h-[40vh]`} >
diff --git a/src/modules/assets/components/overview/InventoryDesktopTable.tsx b/src/modules/assets/components/overview/InventoryDesktopTable.tsx index 601abb2..311982b 100644 --- a/src/modules/assets/components/overview/InventoryDesktopTable.tsx +++ b/src/modules/assets/components/overview/InventoryDesktopTable.tsx @@ -1,158 +1,90 @@ +import { Fragment, type Dispatch, type SetStateAction } from 'react'; +import { ArrowUpRight, ChevronRight } from 'lucide-react'; import { InventoryStatusCounts } from './InventoryStatusCounts'; -import React from 'react'; -import { ChevronDown, ChevronRight } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; -import type { InventoryStatisticsSectionProps } from './types'; +import { InventoryModelTags } from './InventoryModelTags'; +import { inventoryBrandGroups, inventorySelection, type InventoryDisplayGroup } from './inventory-presentation'; +import type { InventoryFilters, VehicleModalSelection } from '../../model'; +import type { RegionalInventoryStats } from '../../types'; -type InventoryDesktopTableProps = Pick< - InventoryStatisticsSectionProps, - | 'inventoryTab' - | 'inventoryByRegion' - | 'expandedInventoryRegions' - | 'toggleInventoryRegion' - | 'inventoryByModel' - | 'expandedInventoryTypes' - | 'toggleInventoryType' - | 'setVehicleSelection' ->; +export interface InventoryListProps { + groups: InventoryDisplayGroup[]; + tab: 'region' | 'model'; + expanded: Set; + toggle: (name: string) => void; + total: number; + filters: InventoryFilters; + select: Dispatch>; +} -export function InventoryDesktopTable({ - inventoryTab, - inventoryByRegion, - expandedInventoryRegions, - toggleInventoryRegion, - inventoryByModel, - expandedInventoryTypes, - toggleInventoryType, - setVehicleSelection: setShowPlateNumbers, -}: InventoryDesktopTableProps) { - return ( -
- - - - - - - - - - - {inventoryTab === 'region' ? ( - Object.entries(inventoryByRegion).map(([region, cities]) => ( - - toggleInventoryRegion(region)} - > - - - - {expandedInventoryRegions.has(region) && Object.entries(cities).map(([city, stats]) => ( - - {stats.map((stat, idx) => ( - - - - - - - ))} - - ))} - - - )) - ) : ( - Object.entries(inventoryByModel).map(([type, models]) => ( - - toggleInventoryType(type)} - > - - - - {expandedInventoryTypes.has(type) && Object.entries(models).map(([model, stats]) => ( - - {stats.map((stat, idx) => ( - - - - - - - ))} - - ))} - - - )) - )} +export function InventoryQuantity({ quantity, label, onClick }: { quantity: number; label: string; onClick: () => void }) { + return ; +} + +export function selectInventoryRow(row: RegionalInventoryStats, select: InventoryListProps['select']) { + select(inventorySelection(row, `库存统计 · ${row.city} · ${row.model}`)); +} + +export function selectInventoryGroup(group: InventoryDisplayGroup, { tab, filters, select }: Pick) { + select(inventorySelection(filters, `库存统计 · ${group.name}`, tab === 'region' + ? { location: filters.city || group.name } + : { vehicleType: group.name })); +} + +export function InventoryShare({ quantity, total }: { quantity: number; total: number }) { + const percent = total ? quantity / total * 100 : 0; + return
; +} + +function InventoryDetailRows({ rows, city, tab, total, select }: Pick & { rows: RegionalInventoryStats[]; city?: string }) { + const brands = inventoryBrandGroups(rows, tab === 'region' ? 'type' : 'model'); + return brands.map((brand, brandIndex) => brand.variants.map((variants, variantIndex) => variants.map((row, index) => +
+ {tab === 'region' && brandIndex === 0 && variantIndex === 0 && index === 0 && } + {variantIndex === 0 && index === 0 && } + {index === 0 && } + + + + + ))); +} + +export function InventoryDesktopTable(props: InventoryListProps) { + const { groups, tab, expanded, toggle, total, select } = props; + return
+
{inventoryTab === 'region' ? '区域 / 城市' : '车型分类 / 型号'}{inventoryTab === 'region' ? '品牌' : '品牌'}{inventoryTab === 'region' ? '车型' : '所在区域/城市'}库存数量
-
- {expandedInventoryRegions.has(region) ? : } - {region} - { - e.stopPropagation(); - setShowPlateNumbers({ batch: 'All', model: 'All', location: region, category: 'Inventory', source: 'asset', title: `库存统计 - ${region}` }); - }}> - (共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台) - - -
-
- {idx === 0 ? {city} : ''} - {stat.brand}{stat.model} - -
-
-
- {expandedInventoryTypes.has(type) ? : } - {type} - { - e.stopPropagation(); - setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', type: type, category: 'Inventory', source: 'asset', title: `库存统计 - ${type}` }); - }}> - (共 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台) - - -
-
- {idx === 0 ? {model} : ''} - {stat.brand}{stat.region} / {stat.city} - -
-
{city || '未分配'}{brand.brand || '—'} + {tab === 'region' ? {row.type || '未分类'} : } + {tab === 'region' ? : <>{row.region}{row.city}} selectInventoryRow(row, select)} />
+ + + {tab === 'region' && } + + {tab === 'region' && } + + {tab === 'model' && } + + + + {groups.map(group => + + + + + -
按{tab === 'region' ? '区域' : '车型'}分组的库存统计,占比基于当前筛选库存总数
区域 / 城市品牌车型类别 / 颜色 / 归属方区域 / 城市库存数量库存占比
+
+ + +
+
selectInventoryGroup(group, props)} />
-
- ); + {expanded.has(group.name) && (tab === 'region' ? group.sections : [[group.name, group.rows] as const]).map(([name, rows]) => + + )} + )} + +
; } diff --git a/src/modules/assets/components/overview/InventoryMobileList.tsx b/src/modules/assets/components/overview/InventoryMobileList.tsx index 3708293..235d4ba 100644 --- a/src/modules/assets/components/overview/InventoryMobileList.tsx +++ b/src/modules/assets/components/overview/InventoryMobileList.tsx @@ -1,122 +1,37 @@ +import { ChevronRight } from 'lucide-react'; import { InventoryStatusCounts } from './InventoryStatusCounts'; -import { ChevronDown, ChevronRight } from 'lucide-react'; -import type { InventoryStatisticsSectionProps } from './types'; +import { InventoryModelTags } from './InventoryModelTags'; +import { inventoryBrandGroups } from './inventory-presentation'; +import { InventoryQuantity, InventoryShare, selectInventoryGroup, selectInventoryRow, type InventoryListProps } from './InventoryDesktopTable'; -type InventoryMobileListProps = Pick< - InventoryStatisticsSectionProps, - | 'inventoryTab' - | 'inventoryByRegion' - | 'expandedInventoryRegions' - | 'toggleInventoryRegion' - | 'inventoryByModel' - | 'expandedInventoryTypes' - | 'toggleInventoryType' - | 'setVehicleSelection' ->; - -export function InventoryMobileList({ - inventoryTab, - inventoryByRegion, - expandedInventoryRegions, - toggleInventoryRegion, - inventoryByModel, - expandedInventoryTypes, - toggleInventoryType, - setVehicleSelection: setShowPlateNumbers, -}: InventoryMobileListProps) { - return ( -
- {inventoryTab === 'region' ? ( - Object.entries(inventoryByRegion).map(([region, cities]) => ( -
-
toggleInventoryRegion(region)} - > -
- {expandedInventoryRegions.has(region) ? : } - {region} -
- - {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台 - - -
- - {expandedInventoryRegions.has(region) && ( -
- {Object.entries(cities).map(([city, stats]) => ( -
-
{city}
-
- {stats.map((stat, idx) => ( -
-
- {stat.brand} - {stat.model} -
- - {(stat.abnormal || stat.pending) ?
: null} -
- ))} -
-
- ))} -
- )} -
- )) - ) : ( - Object.entries(inventoryByModel).map(([type, models]) => ( -
-
toggleInventoryType(type)} - > -
- {expandedInventoryTypes.has(type) ? : } - {type} -
- - {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台 - - -
- - {expandedInventoryTypes.has(type) && ( -
- {Object.entries(models).map(([model, stats]) => ( -
-
{model}
-
- {stats.map((stat, idx) => ( -
-
- {stat.brand} - {stat.region} / {stat.city} -
- - {(stat.abnormal || stat.pending) ?
: null} -
- ))} -
-
- ))} -
- )} -
- )) - )} -
- ); +export function InventoryMobileList(props: InventoryListProps) { + const { groups, tab, expanded, toggle, total, select } = props; + return
+ {groups.map(group =>
+
+ + selectInventoryGroup(group, props)} /> +
+
库存占比
+ {(group.rows.some(row => row.abnormal || row.pending)) &&
} + {expanded.has(group.name) &&
+ {(tab === 'region' ? group.sections : [[group.name, group.rows] as const]).map(([name, rows]) =>
+ {tab === 'region' &&

{name || '未分配'}

} + {inventoryBrandGroups(rows, tab === 'region' ? 'type' : 'model').map(brand =>
+

{brand.brand || '未分配品牌'}

+ {brand.variants.map((variants, variantIndex) =>
+
{tab === 'region' ? {variants[0].type || '未分类'} : }
+ {variants.map((row, index) =>
+
{tab === 'region' ? : <>{row.region}{row.city}}
+ selectInventoryRow(row, select)} /> + {(row.abnormal || row.pending) ?
: null} +
)} +
)} +
)} +
)} +
} +
)} +
; } diff --git a/src/modules/assets/components/overview/InventoryModelTags.tsx b/src/modules/assets/components/overview/InventoryModelTags.tsx new file mode 100644 index 0000000..753ee22 --- /dev/null +++ b/src/modules/assets/components/overview/InventoryModelTags.tsx @@ -0,0 +1,11 @@ +import type { RegionalInventoryStats } from '../../types'; +import { inventoryModelTags } from './inventory-presentation'; + +export function InventoryModelTags({ row, hideType = false }: { row: Pick; hideType?: boolean }) { + const tags = inventoryModelTags(row).filter(tag => !hideType || tag.kind !== 'type'); + return + {tags.length ? tags.map(tag => + {tag.label}:{tag.value} + ) : '—'} + ; +} diff --git a/src/modules/assets/components/overview/InventoryStatisticsSection.tsx b/src/modules/assets/components/overview/InventoryStatisticsSection.tsx index 35b62ce..ad0ca91 100644 --- a/src/modules/assets/components/overview/InventoryStatisticsSection.tsx +++ b/src/modules/assets/components/overview/InventoryStatisticsSection.tsx @@ -1,211 +1,121 @@ -import { InventoryStatusCounts } from './InventoryStatusCounts'; -import { Filter } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; +import { useId, useRef, useState } from 'react'; +import { ArrowUpRight, Boxes, ChevronDown, ChevronsDownUp, ChevronsUpDown, Filter, MapPin, SearchX, Truck, X } from 'lucide-react'; import { InventoryDesktopTable } from './InventoryDesktopTable'; import { InventoryMobileList } from './InventoryMobileList'; +import { inventoryGroups, inventorySelection, inventoryTotal } from './inventory-presentation'; +import type { InventoryFilters } from '../../model'; import type { InventoryStatisticsSectionProps } from './types'; +import './inventory-statistics.css'; -export function InventoryStatisticsSection({ - inventoryFilters, - setInventoryFilters, - draftInventoryFilters, - setDraftInventoryFilters, - isInventoryFilterOpen, - setIsInventoryFilterOpen, - uniqueInventoryRegions, - uniqueInventoryCities, - uniqueInventoryBrands, - uniqueInventoryTypes, - uniqueInventoryModelsForType, - filteredInventoryStats, - inventoryTab, - setInventoryTab, - inventoryByRegion, - expandedInventoryRegions, - toggleInventoryRegion, - inventoryByModel, - expandedInventoryTypes, - toggleInventoryType, - setVehicleSelection: setShowPlateNumbers, -}: InventoryStatisticsSectionProps) { - const listProps = { - inventoryTab, - inventoryByRegion, - expandedInventoryRegions, - toggleInventoryRegion, - inventoryByModel, - expandedInventoryTypes, - toggleInventoryType, - setVehicleSelection: setShowPlateNumbers, +const emptyFilters: InventoryFilters = { region: '', city: '', brand: '', type: '', model: '' }; +const filterLabels: Record = { region: '区域', city: '城市', brand: '品牌', type: '车型', model: '型号 / 批次' }; + +export function InventoryStatisticsSection(props: InventoryStatisticsSectionProps) { + const { + inventoryFilters: filters, setInventoryFilters: setFilters, + draftInventoryFilters: draft, setDraftInventoryFilters: setDraft, + isInventoryFilterOpen: filterOpen, setIsInventoryFilterOpen: setFilterOpen, + filteredInventoryStats: rows, inventoryTab: tab, setInventoryTab: setTab, + setVehicleSelection: select, + } = props; + const id = useId(); + const filterTrigger = useRef(null); + const [sort, setSort] = useState<'default' | 'quantity'>('default'); + const total = inventoryTotal(rows); + const abnormal = rows.reduce((sum, row) => sum + (row.abnormal || 0), 0); + const pending = rows.reduce((sum, row) => sum + (row.pending || 0), 0); + const activeFilters = (Object.keys(filters) as (keyof InventoryFilters)[]).filter(key => filters[key]); + const groups = inventoryGroups(tab === 'region' ? props.inventoryByRegion : props.inventoryByModel, sort, tab); + const expanded = tab === 'region' ? props.expandedInventoryRegions : props.expandedInventoryTypes; + const toggle = tab === 'region' ? props.toggleInventoryRegion : props.toggleInventoryType; + const allExpanded = groups.length > 0 && groups.every(group => expanded.has(group.name)); + const closeFilters = () => { setFilterOpen(false); filterTrigger.current?.focus(); }; + const resetFilters = () => { setFilters({ ...emptyFilters }); setDraft({ ...emptyFilters }); }; + const options = { + region: props.uniqueInventoryRegions, city: props.uniqueInventoryCities, + brand: props.uniqueInventoryBrands, type: props.uniqueInventoryTypes, + model: props.uniqueInventoryModelsForType, }; + const listProps = { groups, tab, expanded, toggle, total, filters, select }; return ( -
-
-
-
-
-

库存统计

- 实时更新库存数据 -
+
+
+
+ +

库存统计

从区域与车型查看库存分布,点击数量查看车辆明细

+ +
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}> - 总库存数据 - - {filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)} - 台 - -
- -
- - - - {isInventoryFilterOpen && ( - <> - -
-

库存统计 - 数据筛选

- -
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- - )} -
-
-
+
+ + +
+
{new Set(rows.map(row => row.region)).size} 个区域 · {new Set(rows.map(row => row.city)).size} 个城市待交车、异动已包含在库存总数中
- {filteredInventoryStats.some(row => (row.abnormal || 0) > 0 || (row.pending || 0) > 0) && ( -
- - 已包含在库存总数中 + {filterOpen &&
{ if (event.key === 'Escape') { event.preventDefault(); closeFilters(); } }} + onSubmit={event => { event.preventDefault(); setFilters({ ...draft }); closeFilters(); }}> +
筛选库存
+
+ {(Object.keys(filterLabels) as (keyof InventoryFilters)[]).map(key => )}
- )} +
+ + + +
+
} -
-
- - )} + +
} + +
+
+ + +
+
+ +
- {/* Active Filters Bar */} - {(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && ( -
- {inventoryFilters.region && ( - - 区域: {inventoryFilters.region} - - - )} - {inventoryFilters.city && ( - - 城市: {inventoryFilters.city} - - - )} - {inventoryFilters.brand && ( - - 品牌: {inventoryFilters.brand} - - - )} - {inventoryFilters.type && ( - - 车型: {inventoryFilters.type} - - - )} - {inventoryFilters.model && ( - - 批次: {inventoryFilters.model} - - - )} - -
- )} - - {/* Desktop View Table */} - - - {/* Mobile View */} - -
+ {groups.length ? <> : +
} +
共 {groups.length} 个{tab === 'region' ? '区域' : '车型'} · {rows.length} 条记录 · {total.toLocaleString()} 台库存占比基于当前筛选结果 · 单位:台
+
); } diff --git a/src/modules/assets/components/overview/inventory-presentation.test.ts b/src/modules/assets/components/overview/inventory-presentation.test.ts new file mode 100644 index 0000000..b3546d7 --- /dev/null +++ b/src/modules/assets/components/overview/inventory-presentation.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildVehicleModalRequest, groupInventoryByRegion } from '../../model'; +import type { RegionalInventoryStats } from '../../types'; +import { inventoryBrandGroups, inventoryGroups, inventoryModelTags, inventorySelection } from './inventory-presentation'; + +const filters = { region: '华东', city: '上海', brand: '品牌甲', type: '18T', model: '批次一' }; + +test('filtered inventory totals retain all filters in the vehicle request', () => { + const request = buildVehicleModalRequest(inventorySelection(filters, '筛选结果'), '公司甲'); + assert.deepEqual(request, { kind: 'vehicles', params: { + brand: '品牌甲', vehicleType: '18T', model: '批次一', location: '上海', + source: 'asset', category: 'Inventory', subject: '公司甲', + } }); +}); + +test('inventory subsets retain scope while changing category', () => { + for (const category of ['Pending', 'Abnormal'] as const) { + const request = buildVehicleModalRequest(inventorySelection({ ...filters, city: '' }, '子集', { category }), null); + assert.equal(request.kind, 'vehicles'); + if (request.kind !== 'vehicles') throw new Error('wrong request type'); + assert.equal(request.params.location, '华东'); + assert.equal(request.params.category, category); + assert.equal(request.params.vehicleType, '18T'); + assert.equal(request.params.brand, '品牌甲'); + } +}); + +test('quantity sorting preserves source order and counts inventory subsets only once', () => { + const rows: RegionalInventoryStats[] = [ + { ...filters, batch: '', quantity: 8, pending: 2, abnormal: 1 }, + { ...filters, city: '苏州', batch: '', quantity: 3 }, + { ...filters, region: '华南', city: '广州', batch: '', quantity: 20 }, + ]; + const source = groupInventoryByRegion(rows); + assert.deepEqual(inventoryGroups(source, 'quantity').map(group => [group.name, group.quantity]), [['华南', 20], ['华东', 11]]); + assert.deepEqual(inventoryGroups(source, 'default').map(group => group.name), ['华东', '华南']); + assert.deepEqual(inventoryGroups({}, 'quantity'), []); +}); + +test('model tags separate type and qualifiers without inventing a batch', () => { + assert.deepEqual(inventoryModelTags({ type: '4.5T冷链', model: '现代4.5T冷链(交投)', batch: '现代4.5T冷链(交投)' }), [ + { kind: 'type', label: '车型', value: '4.5T冷链' }, + { kind: 'detail', label: '型号说明', value: '交投' }, + ]); + assert.deepEqual(inventoryModelTags({ type: '18T', model: '飞驰18T(白车)', batch: '2026-01' }).map(tag => tag.value), ['18T', '白车', '2026-01']); + assert.deepEqual(inventoryModelTags({ type: '挂车', model: '挂车', batch: '挂车' }).map(tag => tag.value), ['挂车']); + assert.deepEqual(inventoryModelTags({ type: '', model: '未知型号(未闭合', batch: '' }).map(tag => tag.value), ['未知型号(未闭合']); +}); + +test('brand removal preserves body style, color and qualifiers in model tags', () => { + assert.deepEqual(inventoryModelTags({ brand: '帕力安牌', type: '18T', model: '现代18T双飞翼(羚牛)', batch: '' }).map(tag => tag.value), ['18T', '双飞翼', '羚牛']); + assert.deepEqual(inventoryModelTags({ brand: '苏龙', type: '18T', model: '苏龙18T飞翼(白色)', batch: '' }).map(tag => tag.value), ['18T', '飞翼', '白色']); + assert.deepEqual(inventoryModelTags({ brand: '飞驰', type: '49T', model: '49T飞驰(红车)', batch: '' }).map(tag => tag.value), ['49T', '红车']); +}); + +test('brand ordering stays inside city blocks and does not mutate source rows', () => { + const brands = ['其他', '飞驰', '跃进', '帕力安牌', '宇通', '现代', '苏龙', '帕力安', '明威']; + const records = brands.map(brand => ({ ...filters, brand, batch: '', quantity: 1 })); + const source = { 华东: { 上海: records, 苏州: [{ ...records[0], city: '苏州' }] } }; + const result = inventoryGroups(source, 'default'); + assert.deepEqual(result[0].sections.map(([name]) => name), ['上海', '苏州']); + assert.deepEqual(result[0].sections[0][1].map(row => row.brand), ['现代', '帕力安牌', '帕力安', '宇通', '飞驰', '跃进', '苏龙', '明威', '其他']); + assert.deepEqual(records.map(row => row.brand), brands); + assert.equal(result[0].quantity, 10); +}); + +test('type view sorts complete model blocks by brand while preserving model grouping', () => { + const row = { ...filters, batch: '', quantity: 1 }; + const source = { '18T': { + 飞驰白车: [{ ...row, brand: '飞驰' }, { ...row, brand: '飞驰', city: '苏州' }], + 现代双飞翼: [{ ...row, brand: '帕力安牌' }], + 现代普货: [{ ...row, brand: '现代' }], + } }; + const result = inventoryGroups(source, 'default', 'model'); + assert.deepEqual(result[0].sections.map(([name]) => name), ['现代普货', '现代双飞翼', '飞驰白车']); + assert.equal(result[0].sections[2][1].length, 2); +}); + +test('brand and type groups combine nonadjacent variants while retaining all detail records', () => { + const rows: RegionalInventoryStats[] = [ + { ...filters, brand: '飞驰', type: '18T', model: '飞驰18T(白车)', batch: '', quantity: 9 }, + { ...filters, brand: '现代', type: '18T', model: '现代18T双飞翼(羚牛)', batch: '', quantity: 8 }, + { ...filters, brand: '飞驰', type: '49T', model: '49T飞驰', batch: '', quantity: 16 }, + { ...filters, brand: '飞驰', type: '18T', model: '飞驰18T(红车)', batch: '', quantity: 7 }, + ]; + const groups = inventoryBrandGroups(rows, 'type'); + assert.deepEqual(groups.map(group => group.brand), ['现代', '飞驰']); + assert.deepEqual(groups[1].variants.map(variant => variant.map(row => row.quantity)), [[9, 7], [16]]); + assert.equal(groups[1].variants[0][0], rows[0]); + assert.equal(groups[1].variants[0][1], rows[3]); + assert.equal(groups.flatMap(group => group.rows).reduce((sum, row) => sum + row.quantity, 0), 40); + assert.deepEqual(rows.map(row => row.quantity), [9, 8, 16, 7]); + assert.deepEqual(groups[1].variants[0].map(row => inventoryModelTags(row).filter(tag => tag.kind !== 'type').map(tag => tag.value)), [['白车'], ['红车']]); +}); + +test('model grouping merges locations but keeps different brands, colors and batches separate', () => { + const base = { ...filters, brand: '飞驰', type: '18T', model: '飞驰18T(白车)', batch: '一期', quantity: 1 }; + const rows = [base, { ...base, city: '苏州' }, { ...base, batch: '二期' }, { ...base, model: '飞驰18T(红车)' }, { ...base, brand: '现代' }]; + const groups = inventoryBrandGroups(rows, 'model'); + assert.deepEqual(groups.map(group => group.brand), ['现代', '飞驰']); + assert.deepEqual(groups[1].variants.map(variant => variant.length), [2, 1, 1]); + assert.deepEqual(groups[1].variants[0].map(row => row.city), ['上海', '苏州']); + for (const row of groups.flatMap(group => group.rows)) { + assert.equal(inventorySelection(row, '明细').model, row.model); + assert.equal(inventorySelection(row, '明细').brand, row.brand); + assert.equal(inventorySelection(row, '明细').location, row.city); + } +}); diff --git a/src/modules/assets/components/overview/inventory-presentation.ts b/src/modules/assets/components/overview/inventory-presentation.ts new file mode 100644 index 0000000..6416dc8 --- /dev/null +++ b/src/modules/assets/components/overview/inventory-presentation.ts @@ -0,0 +1,93 @@ +import type { InventoryFilters, VehicleModalSelection } from '../../model'; +import type { RegionalInventoryStats } from '../../types'; + +const BRAND_ORDER = ['现代', '帕力安', '宇通', '飞驰', '跃进']; +const normalizeBrand = (brand: string) => brand.trim().replace(/牌$/, ''); + +function compareInventoryBrand(left: string, right: string) { + const rank = (brand: string) => { + const normalized = normalizeBrand(brand); + if (!normalized || normalized === '其他') return BRAND_ORDER.length + 1; + const index = BRAND_ORDER.indexOf(normalized); + return index < 0 ? BRAND_ORDER.length : index; + }; + return rank(left) - rank(right); +} + +export function inventoryTotal(rows: RegionalInventoryStats[]) { + return rows.reduce((total, row) => total + row.quantity, 0); +} + +/** Decompose display text only; never change the model used for drilldown. */ +export function inventoryModelTags(row: Pick & Partial>) { + const tags: { kind: 'type' | 'model' | 'detail' | 'batch'; label: string; value: string }[] = []; + const type = row.type.trim(); + const model = row.model.trim(); + const details = Array.from(model.matchAll(/[((]([^()()]+)[))]/g), match => match[1].trim()).filter(Boolean); + const base = model.replace(/[((]([^()()]+)[))]/g, '').trim(); + // The type already has its own tag, so avoid repeating it in the model tag. + let compactModel = (type ? base.replace(type, '') : base).trim(); + // Alias model names can say “现代” even when the separate brand is “帕力安牌”. + // Strip only a leading brand, preserving body style, color and qualifiers. + const brands = [...new Set([row.brand || '', normalizeBrand(row.brand || ''), ...BRAND_ORDER, '帕力安牌'])] + .filter(brand => brand && brand !== '其他').sort((a, b) => b.length - a.length); + const prefix = brands.find(brand => compactModel.startsWith(brand)); + if (prefix) compactModel = compactModel.slice(prefix.length).replace(/^[\s-]+/, '').trim(); + if (type) tags.push({ kind: 'type', label: '车型', value: type }); + if (compactModel) tags.push({ kind: 'model', label: '型号', value: compactModel }); + for (const value of new Set(details)) tags.push({ kind: 'detail', label: '型号说明', value }); + const batch = row.batch?.trim(); + // The current API aliases batch to model; only render genuinely distinct values. + if (batch && batch !== model) tags.push({ kind: 'batch', label: '批次', value: batch }); + return tags; +} + +/** Preserve the displayed scope when opening a filtered total or group. */ +export function inventorySelection(filters: InventoryFilters, title: string, overrides: Partial = {}): VehicleModalSelection { + return { + batch: 'All', model: filters.model || 'All', + location: filters.city || filters.region || 'All', + brand: filters.brand || undefined, vehicleType: filters.type || undefined, + category: 'Inventory', source: 'asset', title, ...overrides, + }; +} + +export interface InventoryDisplayGroup { + name: string; + sections: [string, RegionalInventoryStats[]][]; + rows: RegionalInventoryStats[]; + quantity: number; +} + +/** Group presentation only: keep each original record for quantity and drilldown. */ +export function inventoryBrandGroups(rows: RegionalInventoryStats[], groupBy: 'type' | 'model') { + const brands = new Map }>(); + for (const row of rows) { + const key = normalizeBrand(row.brand); + let brand = brands.get(key); + if (!brand) { + brand = { brand: row.brand, rows: [], variants: new Map() }; + brands.set(key, brand); + } + brand.rows.push(row); + const variantKey = groupBy === 'type' ? row.type : JSON.stringify([row.type, row.model, row.batch]); + const variant = brand.variants.get(variantKey) || []; + variant.push(row); + brand.variants.set(variantKey, variant); + } + return [...brands.values()].sort((a, b) => compareInventoryBrand(a.brand, b.brand)) + .map(brand => ({ ...brand, variants: [...brand.variants.values()] })); +} + +export function inventoryGroups(source: Record>, sort: 'default' | 'quantity', groupBy: 'region' | 'model' = 'region'): InventoryDisplayGroup[] { + const groups = Object.entries(source).map(([name, children]) => { + const sections: InventoryDisplayGroup['sections'] = Object.entries(children).map(([section, records]) => [ + section, [...records].sort((a, b) => compareInventoryBrand(a.brand, b.brand)), + ]); + // Keep city blocks intact; in the type view order whole model blocks by brand. + if (groupBy === 'model') sections.sort(([, a], [, b]) => compareInventoryBrand(a[0]?.brand || '', b[0]?.brand || '')); + const rows = sections.flatMap(([, records]) => records); + return { name, sections, rows, quantity: inventoryTotal(rows) }; + }); + return sort === 'quantity' ? groups.sort((a, b) => b.quantity - a.quantity) : groups; +} diff --git a/src/modules/assets/components/overview/inventory-statistics.css b/src/modules/assets/components/overview/inventory-statistics.css new file mode 100644 index 0000000..dfedcb1 --- /dev/null +++ b/src/modules/assets/components/overview/inventory-statistics.css @@ -0,0 +1,175 @@ +.inventory-statistics { + --inventory-bg: #fff; + --inventory-soft: #f8fafc; + --inventory-line: #e2e8f0; + --inventory-text: #17243b; + --inventory-muted: #59687c; + --inventory-accent: #2458cf; + --inventory-accent-soft: #eff5ff; + --inventory-warning: #a34d0a; + min-width: 0; + margin-bottom: 24px; + overflow: hidden; + border: 1px solid var(--inventory-line); + border-radius: 16px; + background: var(--inventory-bg); + color: var(--inventory-text); + box-shadow: 0 2px 6px rgb(15 23 42 / 3%); + font-size: 12px; + line-height: 1.5; +} +.inventory-statistics button { cursor: pointer; } +.inventory-statistics button:disabled { cursor: default; } +.inventory-statistics :is(button, select):focus-visible { outline: 3px solid var(--inventory-accent); outline-offset: 3px; } +.inventory-statistics button:active:not(:disabled) { opacity: .75; } +.inventory-heading, .inventory-toolbar, .inventory-filter-heading, .inventory-filter-actions, .inventory-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.inventory-heading { padding: 10px 16px; } +.inventory-title { display: flex; align-items: center; gap: 12px; min-width: 0; } +.inventory-title-icon { display: grid; place-items: center; flex-shrink: 0; width: 30px; height: 30px; border-radius: 8px; color: var(--inventory-accent); background: var(--inventory-accent-soft); } +.inventory-title h2 { font-size: 15px; font-weight: 700; } +.inventory-title p { margin-top: 0; font-size: 12px; color: var(--inventory-muted); } +.inventory-control, .inventory-text-button, .inventory-icon-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; border-radius: 6px; font-weight: 500; white-space: nowrap; } +.inventory-control { padding: 4px 10px; border: 1px solid var(--inventory-line); background: var(--inventory-bg); } +.inventory-control[aria-expanded="true"], .inventory-control:hover { color: var(--inventory-accent); border-color: var(--inventory-accent); } +.inventory-control-primary, .inventory-control-primary:hover { background: var(--inventory-accent); color: white; border-color: var(--inventory-accent); } +.inventory-text-button { padding: 4px 8px; color: var(--inventory-muted); } +.inventory-text-button:hover { color: var(--inventory-accent); background: var(--inventory-accent-soft); } +.inventory-icon-button { width: 32px; color: var(--inventory-muted); } +.inventory-filter-count { display: grid; place-items: center; height: 20px; min-width: 20px; border-radius: 5px; background: var(--inventory-accent-soft); color: var(--inventory-accent); font-size: 12px; } +.inventory-metrics { display: grid; grid-template-columns: 1.25fr 1fr 1fr; margin: 0 16px; border: 1px solid var(--inventory-line); border-radius: 8px; overflow: hidden; } +.inventory-metric { position: relative; padding: 8px 12px; text-align: left; min-width: 0; background: var(--inventory-bg); } +.inventory-metric + .inventory-metric { border-left: 1px solid var(--inventory-line); } +.inventory-metric > span:first-child { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--inventory-muted); } +.inventory-metric strong { display: block; font-size: 22px; font-weight: 650; letter-spacing: -.03em; line-height: 1.25; margin: 2px 0 0; font-variant-numeric: tabular-nums; } +.inventory-metric small { color: var(--inventory-muted); font-size: 12px; font-weight: 400; margin-left: 6px; } +.inventory-summary-note { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; padding: 4px 16px 8px; font-size: 12px; color: var(--inventory-muted); } +.inventory-metric-primary { background: var(--inventory-accent-soft); } +.inventory-metric-primary strong { color: var(--inventory-accent); } +.inventory-metric-warning:not(:disabled) strong { color: var(--inventory-warning); } +.inventory-metric:disabled > span > svg { visibility: hidden; } +.inventory-metric:not(:disabled):hover { background: var(--inventory-accent-soft); } +.inventory-metric:focus-visible { outline-offset: -4px !important; } +.inventory-filters { padding: 8px 16px 12px; background: var(--inventory-soft); border-block: 1px solid var(--inventory-line); } +.inventory-filter-heading { margin-bottom: 8px; } +.inventory-filter-fields { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; } +.inventory-filter-fields label { display: grid; gap: 4px; font-size: 12px; color: var(--inventory-muted); } +.inventory-statistics select { min-width: 0; max-width: 100%; min-height: 32px; padding: 4px 8px; border: 1px solid var(--inventory-line); border-radius: 8px; background: var(--inventory-bg); color: var(--inventory-text); font-size: 12px; cursor: pointer; } +.inventory-filter-actions { justify-content: flex-end; margin-top: 10px; gap: 8px; } +.inventory-filter-actions > :first-child { margin-right: auto; } +.inventory-active-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 16px 8px; font-size: 12px; color: var(--inventory-muted); } +.inventory-filters + .inventory-active-filters { padding-top: 16px; } +.inventory-active-filters button { display: inline-flex; align-items: center; gap: 8px; padding: 4px 10px; min-height: 36px; border-radius: 6px; background: var(--inventory-accent-soft); color: var(--inventory-accent); } +.inventory-active-filters .inventory-clear { background: transparent; } +.inventory-toolbar { padding: 8px 16px; border-top: 1px solid var(--inventory-line); } +.inventory-view-switch { display: inline-flex; padding: 3px; background: var(--inventory-soft); border: 1px solid var(--inventory-line); border-radius: 10px; } +.inventory-view-switch button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 28px; padding: 4px 12px; border-radius: 6px; color: var(--inventory-muted); font-size: 12px; } +.inventory-view-switch button[aria-pressed="true"] { background: var(--inventory-bg); color: var(--inventory-accent); box-shadow: 0 1px 4px rgb(15 23 42 / 10%); font-weight: 600; } +.inventory-toolbar-actions { display: flex; align-items: center; gap: 12px; } +.inventory-sort select { min-width: 144px; padding-right: 28px; font-size: 12px; } +.inventory-table { width: 100%; border-collapse: collapse; table-layout: fixed; } +.inventory-table thead { background: var(--inventory-soft); color: var(--inventory-muted); font-size: 12px; } +.inventory-table th, .inventory-table td { padding: 4px 16px; border-bottom: 1px solid var(--inventory-line); text-align: left; overflow-wrap: anywhere; } +.inventory-table thead th { font-weight: 500; padding-block: 8px; } +.inventory-table[data-group-by="model"] thead th:first-child { width: 14%; } +.inventory-table[data-group-by="model"] thead th:nth-child(2) { width: 30%; } +.inventory-table[data-group-by="model"] thead th:nth-child(3) { width: 26%; } +.inventory-table[data-group-by="region"] thead th:first-child { width: 12%; } +.inventory-table[data-group-by="region"] thead th:nth-child(2) { width: 12%; } +.inventory-table[data-group-by="region"] thead th:nth-child(3) { width: 16%; } +.inventory-table[data-group-by="region"] thead th:nth-child(4) { width: 30%; } +.inventory-table .inventory-number-column { text-align: right; width: 14%; } +.inventory-table .inventory-share-column { width: 16%; } +.inventory-group-row { background: var(--inventory-soft); } +.inventory-group-row th { font-weight: 400; } +.inventory-group-heading { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; } +.inventory-expand { display: flex; align-items: center; gap: 8px; min-height: 30px; min-width: 0; text-align: left; } +.inventory-expand > svg { color: var(--inventory-muted); flex-shrink: 0; } +.inventory-expand[aria-expanded="true"] > svg { transform: rotate(90deg); } +.inventory-expand strong { font-size: 12px; font-weight: 650; } +.inventory-expand > span { font-size: 12px; font-weight: 400; color: var(--inventory-muted); } +.inventory-quantity { display: inline-flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 44px; min-height: 30px; border-radius: 6px; color: var(--inventory-accent); font-size: 13px; font-weight: 650; font-variant-numeric: tabular-nums; } +.inventory-quantity svg { opacity: .7; } +.inventory-quantity:not(:disabled):hover { text-decoration: underline; text-underline-offset: 4px; } +.inventory-quantity:disabled { color: var(--inventory-muted); } +.inventory-quantity:disabled svg { visibility: hidden; } +.inventory-share { display: flex; align-items: center; justify-content: flex-end; gap: 10px; color: var(--inventory-muted); font-size: 12px; font-variant-numeric: tabular-nums; } +.inventory-share > span:last-child { min-width: 45px; text-align: right; } +.inventory-share-track { flex: 1; height: 5px; border-radius: 4px; background: var(--inventory-line); overflow: hidden; } +.inventory-share-track > span { display: block; height: 100%; border-radius: inherit; background: var(--inventory-accent); } +.inventory-detail-row { font-size: 12px; } +.inventory-detail-row:hover { background: var(--inventory-accent-soft); } +.inventory-detail-row .inventory-merged-cell { font-weight: 500; padding: 10px 16px; vertical-align: top; background: var(--inventory-bg); border-right: 1px solid var(--inventory-line); } +.inventory-detail-row .inventory-city-cell { padding-left: 36px; } +.inventory-detail-group + .inventory-detail-group { border-top: 1px solid var(--inventory-line); } +.inventory-place-region { margin-right: 8px; color: var(--inventory-muted); } +.inventory-model-tags { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; max-width: 100%; vertical-align: middle; } +.inventory-tag { display: inline-block; max-width: 100%; padding: 1px 6px; border: 1px solid var(--inventory-line); border-radius: 4px; background: var(--inventory-soft); color: var(--inventory-text); font-size: 12px; font-weight: 400; line-height: 18px; overflow-wrap: anywhere; } +.inventory-tag-type { background: var(--inventory-accent-soft); border-color: #dbe6fb; color: var(--inventory-accent); } +.inventory-tag-detail, .inventory-tag-batch { background: #f5f3ff; border-color: #e7e1f6; color: #69518b; } +.inventory-detail-row .inventory-share-track > span { opacity: .45; } +.inventory-statistics [data-testid="inventory-status-counts"] { font-size: 12px; } +.inventory-number-column [data-testid="inventory-status-counts"] { display: flex; justify-content: flex-end; } +.inventory-footer { flex-wrap: wrap; padding: 8px 16px; font-size: 12px; color: var(--inventory-muted); } +.inventory-empty { display: grid; justify-items: center; gap: 12px; padding: 48px 20px; text-align: center; color: var(--inventory-muted); border-block: 1px solid var(--inventory-line); } +.inventory-empty h3 { color: var(--inventory-text); font-weight: 600; font-size: 16px; } +.inventory-empty p { font-size: 13px; } +.inventory-mobile { display: none; } +@media (prefers-reduced-motion: no-preference) { + .inventory-statistics button, .inventory-expand > svg { transition: background-color 150ms ease, color 150ms ease, transform 150ms ease; } +} +@media (max-width: 1023px) { + .inventory-desktop { display: none; } + .inventory-mobile { display: grid; gap: 8px; padding: 0 16px 8px; } + .inventory-mobile-group { border: 1px solid var(--inventory-line); border-radius: 10px; overflow: hidden; } + .inventory-mobile-group-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 12px 0; } + .inventory-mobile-group-meta { display: flex; align-items: center; gap: 12px; padding: 0 12px 8px; font-size: 12px; color: var(--inventory-muted); } + .inventory-mobile-group-meta .inventory-share { flex: 1; } + .inventory-mobile-status { padding: 0 12px 6px; } + .inventory-mobile-details { border-top: 1px solid var(--inventory-line); padding: 0 12px 4px; } + .inventory-mobile-details h3 { padding: 8px 0 4px; font-size: 12px; font-weight: 600; color: var(--inventory-muted); overflow-wrap: anywhere; } + .inventory-mobile-brand { padding-block: 6px; border-top: 1px solid var(--inventory-line); } + .inventory-mobile-brand h4 { font-size: 12px; font-weight: 600; margin-bottom: 4px; } + .inventory-mobile-variant { padding-left: 10px; border-left: 2px solid var(--inventory-line); } + .inventory-mobile-variant + .inventory-mobile-variant { margin-top: 8px; } + .inventory-mobile-variant-heading { padding-top: 2px; } + .inventory-mobile-row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 4px 8px; padding: 4px 0; border-bottom: 1px solid var(--inventory-line); } + .inventory-mobile-row:last-child { border-bottom: 0; } + .inventory-mobile-row > div:first-child { flex: 1; min-width: 0; } + .inventory-mobile-row strong { font-size: 12px; font-weight: 500; overflow-wrap: anywhere; } + .inventory-mobile-row p { font-size: 12px; color: var(--inventory-muted); margin-top: 2px; } + .inventory-mobile-row .inventory-mobile-status { width: 100%; padding: 0; } + .inventory-filter-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} +@media (max-width: 639px) { + .inventory-heading { padding: 10px 12px; align-items: flex-start; gap: 10px; } + .inventory-title { gap: 8px; } + .inventory-title-icon { display: none; } + .inventory-title h2 { font-size: 15px; } + .inventory-title p { max-width: 24ch; } + .inventory-heading .inventory-control { padding: 8px 10px; } + .inventory-heading .inventory-control > svg:last-child { display: none; } + .inventory-metrics { margin: 0 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); } + .inventory-metric { padding: 8px; } + .inventory-metric strong { font-size: 20px; } + .inventory-metric > span:first-child { font-size: 12px; } + .inventory-toolbar { align-items: stretch; flex-direction: column; gap: 6px; padding: 8px 12px; } + .inventory-view-switch button { flex: 1; min-height: 36px; } + .inventory-toolbar-actions { justify-content: space-between; gap: 8px; } + .inventory-toolbar-actions .inventory-text-button { font-size: 12px; } + .inventory-mobile { padding: 0 12px 8px; } + .inventory-mobile-group-heading { padding-inline: 12px; } + .inventory-expand { gap: 6px; } + .inventory-expand strong { font-size: 12px; } + .inventory-expand > span { font-size: 12px; } + .inventory-filters { padding: 8px 16px 16px; } + .inventory-filter-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } + .inventory-filter-fields label:last-child { grid-column: 1 / -1; } + .inventory-filter-fields select { font-size: 16px; min-height: 44px; width: 100%; } + .inventory-filter-actions { gap: 6px; font-size: 12px; } + .inventory-active-filters { padding-inline: 16px; } + .inventory-active-filters button { min-height: 44px; } + .inventory-footer { padding: 8px 12px; gap: 4px; } + .inventory-summary-note { padding: 4px 12px 8px; } + .inventory-metric > span:first-child > svg { display: none; } + .inventory-mobile :is(.inventory-quantity, .inventory-expand) { min-height: 44px; } +} diff --git a/src/server/routes/vehicles/abnormal-move.test.ts b/src/server/routes/vehicles/abnormal-move.test.ts index 81f4867..c6b2636 100644 --- a/src/server/routes/vehicles/abnormal-move.test.ts +++ b/src/server/routes/vehicles/abnormal-move.test.ts @@ -27,3 +27,11 @@ test('completed movement retains its actual time and missing records stay distin assert.equal(completed.abnormalMove?.work, null); assert.equal(transformRow(row).abnormalMove, null); }); + + +test('applicant comes from the movement creator name and never falls back to contract manager', () => { + const base = { ...row, abnormal_move_id: 'move-1' }; + assert.equal(transformRow({ ...base, abnormal_move_applicant: ' 测试申请人 ' }).abnormalMove?.applicant, '测试申请人'); + assert.equal(transformRow({ ...base, abnormal_move_applicant: ' ' }).abnormalMove?.applicant, null); + assert.equal(transformRow(base).abnormalMove?.applicant, null); +}); diff --git a/src/server/routes/vehicles/model.ts b/src/server/routes/vehicles/model.ts index 9dbaa27..f793c3d 100644 --- a/src/server/routes/vehicles/model.ts +++ b/src/server/routes/vehicles/model.ts @@ -221,6 +221,7 @@ export function transformRow(row: VehicleRow): Vehicle { type: ({ 1: '维修', 2: '保养', 3: '年审', 4: '其他' } as Record)[Number(row.abnormal_move_type)] || '未提供', status: Number(row.abnormal_move_status) === 5 ? '已完成' : '待结束', work: row.abnormal_move_work?.trim() || null, + applicant: row.abnormal_move_applicant?.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. diff --git a/src/server/routes/vehicles/repository.ts b/src/server/routes/vehicles/repository.ts index b723faf..f6b7a1c 100644 --- a/src/server/routes/vehicles/repository.ts +++ b/src/server/routes/vehicles/repository.ts @@ -39,6 +39,7 @@ export const MAIN_SQL = `SELECT CAST(vi.id AS CHAR) AS id, CAST(am.id AS CHAR) AS abnormal_move_id, am.move_type AS abnormal_move_type, + NULLIF(TRIM(am_applicant.nick_name), '') AS abnormal_move_applicant, CAST(pdv.id AS CHAR) AS pending_delivery_id, pdts.contract_code AS pending_contract_no, pdts.customer_name AS pending_customer_name, @@ -147,6 +148,7 @@ LEFT JOIN vehicle_abnormal_move_record amr ON amr.id = ( LIMIT 1 ) LEFT JOIN vehicle_abnormal_move am ON am.id = amr.move_id +LEFT JOIN \`ry-cloud\`.sys_user am_applicant ON am_applicant.user_id = am.create_by ${LATEST_VEHICLE_LEASE_JOIN_SQL} WHERE vi.del_flag = '0' AND COALESCE(vs.operation_status, '') <> '5'`; diff --git a/src/server/types.ts b/src/server/types.ts index 731825f..277a623 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -8,6 +8,7 @@ export interface VehicleRow { abnormal_move_type?: number | null; abnormal_move_status?: number | null; abnormal_move_work?: string | null; + abnormal_move_applicant?: string | null; abnormal_move_target_time?: string | null; abnormal_move_actual_time?: string | null; id: string; diff --git a/src/shared/assets/abnormal-move.ts b/src/shared/assets/abnormal-move.ts index c2cd513..f2e6552 100644 --- a/src/shared/assets/abnormal-move.ts +++ b/src/shared/assets/abnormal-move.ts @@ -3,6 +3,8 @@ export interface AbnormalMoveDetail { id: string; type: string; work: string | null; + /** Applicant displayed from the movement creator account, not the vehicle owner. */ + applicant?: string | null; targetCompletionTime: string | null; actualCompletionTime: string | null; initiatingDepartment: string | null;