feat(assets): refine inventory presentation and abnormal vehicle details
This commit is contained in:
@@ -316,17 +316,21 @@ export default function AssetsModule() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleInventoryRegion = (region: string) => {
|
const toggleInventoryRegion = (region: string) => {
|
||||||
const newSet = new Set(expandedInventoryRegions);
|
setExpandedInventoryRegions(current => {
|
||||||
if (newSet.has(region)) newSet.delete(region);
|
const next = new Set(current);
|
||||||
else newSet.add(region);
|
if (next.has(region)) next.delete(region);
|
||||||
setExpandedInventoryRegions(newSet);
|
else next.add(region);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleInventoryType = (type: string) => {
|
const toggleInventoryType = (type: string) => {
|
||||||
const newSet = new Set(expandedInventoryTypes);
|
setExpandedInventoryTypes(current => {
|
||||||
if (newSet.has(type)) newSet.delete(type);
|
const next = new Set(current);
|
||||||
else newSet.add(type);
|
if (next.has(type)) next.delete(type);
|
||||||
setExpandedInventoryTypes(newSet);
|
else next.add(type);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const inventoryTypeFilter = isInventoryFilterOpen
|
const inventoryTypeFilter = isInventoryFilterOpen
|
||||||
|
|||||||
@@ -5,38 +5,44 @@ type DetailsProps = { vehicles: VehicleListItem[] };
|
|||||||
|
|
||||||
function InventoryVehicleDetails({ vehicles, kind }: DetailsProps & { kind: 'abnormal' | 'pending' }) {
|
function InventoryVehicleDetails({ vehicles, kind }: DetailsProps & { kind: 'abnormal' | 'pending' }) {
|
||||||
const pending = kind === 'pending';
|
const pending = kind === 'pending';
|
||||||
const labels = pending ? ['合同编号', '客户名称(已脱敏)'] : ['异动类型', '预计结束时间'];
|
const labels = pending ? ['合同编号', '客户名称(已脱敏)'] : ['异动类型', '具体工作', '申请人', '目标完成时间', '实际完成时间'];
|
||||||
const details = (vehicle: VehicleListItem) => pending
|
const details = (vehicle: VehicleListItem) => pending
|
||||||
? [vehicle.pendingDelivery?.contractNo || '未提供', vehicle.pendingDelivery?.customerName || '未提供']
|
? [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 <div className="py-14 text-center text-sm text-slate-500">暂无符合筛选条件的{pending ? '待交车' : '异动车辆'}</div>;
|
if (vehicles.length === 0) return <div className="py-14 text-center text-sm text-slate-500">暂无符合筛选条件的{pending ? '待交车' : '异动车辆'}</div>;
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<p className="px-4 py-3 text-xs leading-5 text-slate-500">{pending ? '展示当前待交车任务的合同信息,客户名称已脱敏。' : '展示当前异动类型与预计结束时间。'}</p>
|
<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">
|
<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">
|
{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>
|
<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>
|
<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">
|
<dl className="mt-4 space-y-3 text-sm">
|
||||||
{details(vehicle).map((value, index) => <div key={labels[index]} className="grid grid-cols-[7.5rem_minmax(0,1fr)] gap-2">
|
{details(vehicle).map((value, index) => <div key={labels[index]} className="grid grid-cols-[6rem_minmax(0,1fr)] gap-2">
|
||||||
<dt className="text-slate-500">{labels[index]}</dt>
|
<dt className="text-slate-500">{labels[index]}</dt>
|
||||||
<dd className="break-words font-medium text-slate-800 [overflow-wrap:anywhere]"><Blur>{value}</Blur></dd>
|
<dd className="whitespace-pre-wrap break-words font-medium text-slate-800 [overflow-wrap:anywhere]"><Blur>{value}</Blur></dd>
|
||||||
</div>)}
|
</div>)}
|
||||||
</dl>
|
</dl>
|
||||||
</article>)}
|
</article>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:block">
|
<div className="hidden overflow-x-auto md:block" tabIndex={0} role="region" aria-label={pending ? "待交车明细表" : "异动明细表,可横向滚动"}>
|
||||||
<table className="w-full min-w-[700px] border-collapse text-left text-xs">
|
<table className={`w-full ${pending ? "min-w-[700px]" : "min-w-[1160px]"} border-collapse text-left text-sm`}>
|
||||||
<thead className="sticky top-0 z-20 bg-slate-700 text-white"><tr>
|
<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>)}
|
{['车牌', '品牌', '车型', '所在地', ...labels].map(label => <th key={label} scope="col" className={`whitespace-nowrap p-3 ${label === "具体工作" ? "w-64" : ""}`}>{label}</th>)}
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>{vehicles.map(vehicle => <tr key={vehicle.id} className="border-b border-slate-200 bg-white align-top">
|
<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 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.brandLabel || '—'}</td>
|
||||||
<td className="p-3 text-slate-600">{vehicle.type}</td>
|
<td className="p-3 text-slate-600">{vehicle.type}</td>
|
||||||
<td className="p-3 text-slate-600">{vehicle.location === '其他' ? '对接中' : vehicle.location}</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>)}
|
{details(vehicle).map((value, index) => <td key={labels[index]} className={`whitespace-pre-wrap break-words p-3 leading-6 text-slate-700 [overflow-wrap:anywhere] ${labels[index] === "具体工作" ? "min-w-56 max-w-72" : "min-w-28 max-w-48"}`}><Blur>{value}</Blur></td>)}
|
||||||
</tr>)}</tbody>
|
</tr>)}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function VehicleDetailModal({
|
|||||||
ref={dialogRef} role="dialog" aria-modal="true" aria-label={showPlateNumbers.title || "车辆明细"} tabIndex={-1}
|
ref={dialogRef} role="dialog" aria-modal="true" aria-label={showPlateNumbers.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' || 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]`}
|
||||||
>
|
>
|
||||||
<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>
|
||||||
|
|||||||
@@ -1,158 +1,90 @@
|
|||||||
|
import { Fragment, type Dispatch, type SetStateAction } from 'react';
|
||||||
|
import { ArrowUpRight, ChevronRight } from 'lucide-react';
|
||||||
import { InventoryStatusCounts } from './InventoryStatusCounts';
|
import { InventoryStatusCounts } from './InventoryStatusCounts';
|
||||||
import React from 'react';
|
import { InventoryModelTags } from './InventoryModelTags';
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
import { inventoryBrandGroups, inventorySelection, type InventoryDisplayGroup } from './inventory-presentation';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import type { InventoryFilters, VehicleModalSelection } from '../../model';
|
||||||
import type { InventoryStatisticsSectionProps } from './types';
|
import type { RegionalInventoryStats } from '../../types';
|
||||||
|
|
||||||
type InventoryDesktopTableProps = Pick<
|
export interface InventoryListProps {
|
||||||
InventoryStatisticsSectionProps,
|
groups: InventoryDisplayGroup[];
|
||||||
| 'inventoryTab'
|
tab: 'region' | 'model';
|
||||||
| 'inventoryByRegion'
|
expanded: Set<string>;
|
||||||
| 'expandedInventoryRegions'
|
toggle: (name: string) => void;
|
||||||
| 'toggleInventoryRegion'
|
total: number;
|
||||||
| 'inventoryByModel'
|
filters: InventoryFilters;
|
||||||
| 'expandedInventoryTypes'
|
select: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||||
| 'toggleInventoryType'
|
}
|
||||||
| 'setVehicleSelection'
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function InventoryDesktopTable({
|
export function InventoryQuantity({ quantity, label, onClick }: { quantity: number; label: string; onClick: () => void }) {
|
||||||
inventoryTab,
|
return <button className="inventory-quantity" disabled={!quantity} onClick={onClick} aria-label={`${label},库存 ${quantity} 台,查看车辆明细`}>
|
||||||
inventoryByRegion,
|
{quantity.toLocaleString()}<ArrowUpRight size={14} aria-hidden="true" />
|
||||||
expandedInventoryRegions,
|
</button>;
|
||||||
toggleInventoryRegion,
|
}
|
||||||
inventoryByModel,
|
|
||||||
expandedInventoryTypes,
|
export function selectInventoryRow(row: RegionalInventoryStats, select: InventoryListProps['select']) {
|
||||||
toggleInventoryType,
|
select(inventorySelection(row, `库存统计 · ${row.city} · ${row.model}`));
|
||||||
setVehicleSelection: setShowPlateNumbers,
|
}
|
||||||
}: InventoryDesktopTableProps) {
|
|
||||||
return (
|
export function selectInventoryGroup(group: InventoryDisplayGroup, { tab, filters, select }: Pick<InventoryListProps, 'tab' | 'filters' | 'select'>) {
|
||||||
<div className="hidden lg:block overflow-x-auto">
|
select(inventorySelection(filters, `库存统计 · ${group.name}`, tab === 'region'
|
||||||
<table className="w-full text-left border-collapse">
|
? { location: filters.city || group.name }
|
||||||
<thead>
|
: { vehicleType: group.name }));
|
||||||
<tr className="bg-slate-50 text-[11px] text-slate-500 uppercase tracking-wider border-b border-slate-100">
|
}
|
||||||
<th className="p-3 font-semibold w-64">{inventoryTab === 'region' ? '区域 / 城市' : '车型分类 / 型号'}</th>
|
|
||||||
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '品牌' : '品牌'}</th>
|
export function InventoryShare({ quantity, total }: { quantity: number; total: number }) {
|
||||||
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '车型' : '所在区域/城市'}</th>
|
const percent = total ? quantity / total * 100 : 0;
|
||||||
<th className="p-3 font-semibold text-center w-32">库存数量</th>
|
return <div className="inventory-share"><span className="inventory-share-track" aria-hidden="true"><span style={{ width: `${percent}%` }} /></span><span>{percent.toFixed(1)}%</span></div>;
|
||||||
</tr>
|
}
|
||||||
</thead>
|
|
||||||
<tbody className="text-xs">
|
function InventoryDetailRows({ rows, city, tab, total, select }: Pick<InventoryListProps, 'tab' | 'total' | 'select'> & { rows: RegionalInventoryStats[]; city?: string }) {
|
||||||
{inventoryTab === 'region' ? (
|
const brands = inventoryBrandGroups(rows, tab === 'region' ? 'type' : 'model');
|
||||||
Object.entries(inventoryByRegion).map(([region, cities]) => (
|
return brands.map((brand, brandIndex) => brand.variants.map((variants, variantIndex) => variants.map((row, index) =>
|
||||||
<React.Fragment key={region}>
|
<tr className="inventory-detail-row" key={`${brandIndex}-${variantIndex}-${index}`}>
|
||||||
<tr
|
{tab === 'region' && brandIndex === 0 && variantIndex === 0 && index === 0 && <th rowSpan={rows.length} scope="rowgroup" className="inventory-merged-cell inventory-city-cell">{city || '未分配'}</th>}
|
||||||
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
|
{variantIndex === 0 && index === 0 && <td rowSpan={brand.rows.length} className="inventory-merged-cell">{brand.brand || '—'}</td>}
|
||||||
onClick={() => toggleInventoryRegion(region)}
|
{index === 0 && <td rowSpan={variants.length} className="inventory-merged-cell">
|
||||||
>
|
{tab === 'region' ? <span className="inventory-tag inventory-tag-type">{row.type || '未分类'}</span> : <InventoryModelTags row={row} hideType />}
|
||||||
<td colSpan={4} className="p-3 font-bold text-slate-700">
|
</td>}
|
||||||
<div className="flex items-center gap-2">
|
<td>{tab === 'region' ? <InventoryModelTags row={row} hideType /> : <><span className="inventory-place-region">{row.region}</span><span>{row.city}</span></>}</td>
|
||||||
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
<td className="inventory-number-column"><InventoryQuantity quantity={row.quantity} label={`${row.city} · ${row.brand} · ${row.model}`} onClick={() => selectInventoryRow(row, select)} /><InventoryStatusCounts rows={[row]} /></td>
|
||||||
<span>{region}</span>
|
<td><InventoryShare quantity={row.quantity} total={total} /></td>
|
||||||
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
|
</tr>
|
||||||
onClick={(e) => {
|
)));
|
||||||
e.stopPropagation();
|
}
|
||||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: region, category: 'Inventory', source: 'asset', title: `库存统计 - ${region}` });
|
|
||||||
}}>
|
export function InventoryDesktopTable(props: InventoryListProps) {
|
||||||
(共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
|
const { groups, tab, expanded, toggle, total, select } = props;
|
||||||
</span>
|
return <div className="inventory-desktop">
|
||||||
<InventoryStatusCounts rows={Object.values(cities).flat()} />
|
<table className="inventory-table" data-group-by={tab}>
|
||||||
</div>
|
<caption className="sr-only">按{tab === 'region' ? '区域' : '车型'}分组的库存统计,占比基于当前筛选库存总数</caption>
|
||||||
</td>
|
<thead><tr>
|
||||||
</tr>
|
{tab === 'region' && <th scope="col">区域 / 城市</th>}
|
||||||
<AnimatePresence>
|
<th scope="col">品牌</th>
|
||||||
{expandedInventoryRegions.has(region) && Object.entries(cities).map(([city, stats]) => (
|
{tab === 'region' && <th scope="col">车型</th>}
|
||||||
<React.Fragment key={city}>
|
<th scope="col">类别 / 颜色 / 归属方</th>
|
||||||
{stats.map((stat, idx) => (
|
{tab === 'model' && <th scope="col">区域 / 城市</th>}
|
||||||
<motion.tr
|
<th scope="col" className="inventory-number-column">库存数量</th>
|
||||||
key={`${city}-${stat.model}-${idx}`}
|
<th scope="col" className="inventory-share-column">库存占比</th>
|
||||||
initial={{ opacity: 0 }}
|
</tr></thead>
|
||||||
animate={{ opacity: 1 }}
|
{groups.map(group => <Fragment key={group.name}><tbody>
|
||||||
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
|
<tr className="inventory-group-row">
|
||||||
>
|
<th scope="row" colSpan={tab === 'region' ? 4 : 3}>
|
||||||
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
|
<div className="inventory-group-heading">
|
||||||
{idx === 0 ? <span className="font-medium text-slate-600">{city}</span> : ''}
|
<button className="inventory-expand" aria-expanded={expanded.has(group.name)} onClick={() => toggle(group.name)}>
|
||||||
</td>
|
<ChevronRight size={16} aria-hidden="true" /><strong>{group.name || '未分配'}</strong><span>{group.sections.length} 个{tab === 'region' ? '城市' : '型号'}</span>
|
||||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
|
</button>
|
||||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.model}</td>
|
<InventoryStatusCounts rows={group.rows} />
|
||||||
<td className="p-3 text-center font-bold text-blue-600">
|
</div>
|
||||||
<button
|
</th>
|
||||||
onClick={(e) => {
|
<td className="inventory-number-column"><InventoryQuantity quantity={group.quantity} label={group.name} onClick={() => selectInventoryGroup(group, props)} /></td>
|
||||||
e.stopPropagation();
|
<td><InventoryShare quantity={group.quantity} total={total} /></td>
|
||||||
setShowPlateNumbers({ batch: 'All', model: stat.model, brand: stat.brand, vehicleType: stat.type, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` });
|
</tr>
|
||||||
}}
|
|
||||||
className="text-blue-600 hover:underline font-bold"
|
|
||||||
>
|
|
||||||
{stat.quantity}
|
|
||||||
</button>
|
|
||||||
<div className="mt-1"><InventoryStatusCounts rows={[stat]} /></div>
|
|
||||||
</td>
|
|
||||||
</motion.tr>
|
|
||||||
))}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</React.Fragment>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
Object.entries(inventoryByModel).map(([type, models]) => (
|
|
||||||
<React.Fragment key={type}>
|
|
||||||
<tr
|
|
||||||
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
|
|
||||||
onClick={() => toggleInventoryType(type)}
|
|
||||||
>
|
|
||||||
<td colSpan={4} className="p-3 font-bold text-slate-700">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
|
||||||
<span>{type}</span>
|
|
||||||
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
|
|
||||||
onClick={(e) => {
|
|
||||||
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)} 台)
|
|
||||||
</span>
|
|
||||||
<InventoryStatusCounts rows={Object.values(models).flat()} />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<AnimatePresence>
|
|
||||||
{expandedInventoryTypes.has(type) && Object.entries(models).map(([model, stats]) => (
|
|
||||||
<React.Fragment key={model}>
|
|
||||||
{stats.map((stat, idx) => (
|
|
||||||
<motion.tr
|
|
||||||
key={`${model}-${stat.region}-${stat.city}-${idx}`}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
|
|
||||||
>
|
|
||||||
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
|
|
||||||
{idx === 0 ? <span className="font-medium text-slate-600">{model}</span> : ''}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
|
|
||||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.region} / {stat.city}</td>
|
|
||||||
<td className="p-3 text-center font-bold text-blue-600">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setShowPlateNumbers({ batch: 'All', model: stat.model, brand: stat.brand, vehicleType: stat.type, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` });
|
|
||||||
}}
|
|
||||||
className="text-blue-600 hover:underline font-bold"
|
|
||||||
>
|
|
||||||
{stat.quantity}
|
|
||||||
</button>
|
|
||||||
<div className="mt-1"><InventoryStatusCounts rows={[stat]} /></div>
|
|
||||||
</td>
|
|
||||||
</motion.tr>
|
|
||||||
))}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</React.Fragment>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
{expanded.has(group.name) && (tab === 'region' ? group.sections : [[group.name, group.rows] as const]).map(([name, rows]) => <tbody key={name} className="inventory-detail-group">
|
||||||
</div>
|
<InventoryDetailRows rows={rows} city={name} tab={tab} total={total} select={select} />
|
||||||
);
|
</tbody>)}
|
||||||
|
</Fragment>)}
|
||||||
|
</table>
|
||||||
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +1,37 @@
|
|||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
import { InventoryStatusCounts } from './InventoryStatusCounts';
|
import { InventoryStatusCounts } from './InventoryStatusCounts';
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
import { InventoryModelTags } from './InventoryModelTags';
|
||||||
import type { InventoryStatisticsSectionProps } from './types';
|
import { inventoryBrandGroups } from './inventory-presentation';
|
||||||
|
import { InventoryQuantity, InventoryShare, selectInventoryGroup, selectInventoryRow, type InventoryListProps } from './InventoryDesktopTable';
|
||||||
|
|
||||||
type InventoryMobileListProps = Pick<
|
export function InventoryMobileList(props: InventoryListProps) {
|
||||||
InventoryStatisticsSectionProps,
|
const { groups, tab, expanded, toggle, total, select } = props;
|
||||||
| 'inventoryTab'
|
return <div className="inventory-mobile">
|
||||||
| 'inventoryByRegion'
|
{groups.map(group => <article key={group.name} className="inventory-mobile-group">
|
||||||
| 'expandedInventoryRegions'
|
<div className="inventory-mobile-group-heading">
|
||||||
| 'toggleInventoryRegion'
|
<button className="inventory-expand" aria-expanded={expanded.has(group.name)} onClick={() => toggle(group.name)}>
|
||||||
| 'inventoryByModel'
|
<ChevronRight size={16} aria-hidden="true" /><strong>{group.name || '未分配'}</strong><span>{group.sections.length} 个{tab === 'region' ? '城市' : '型号'}</span>
|
||||||
| 'expandedInventoryTypes'
|
</button>
|
||||||
| 'toggleInventoryType'
|
<InventoryQuantity quantity={group.quantity} label={group.name} onClick={() => selectInventoryGroup(group, props)} />
|
||||||
| 'setVehicleSelection'
|
</div>
|
||||||
>;
|
<div className="inventory-mobile-group-meta"><span>库存占比</span><InventoryShare quantity={group.quantity} total={total} /></div>
|
||||||
|
{(group.rows.some(row => row.abnormal || row.pending)) && <div className="inventory-mobile-status"><InventoryStatusCounts rows={group.rows} /></div>}
|
||||||
export function InventoryMobileList({
|
{expanded.has(group.name) && <div className="inventory-mobile-details">
|
||||||
inventoryTab,
|
{(tab === 'region' ? group.sections : [[group.name, group.rows] as const]).map(([name, rows]) => <section key={name}>
|
||||||
inventoryByRegion,
|
{tab === 'region' && <h3>{name || '未分配'}</h3>}
|
||||||
expandedInventoryRegions,
|
{inventoryBrandGroups(rows, tab === 'region' ? 'type' : 'model').map(brand => <section className="inventory-mobile-brand" key={brand.brand}>
|
||||||
toggleInventoryRegion,
|
<h4>{brand.brand || '未分配品牌'}</h4>
|
||||||
inventoryByModel,
|
{brand.variants.map((variants, variantIndex) => <section className="inventory-mobile-variant" key={variantIndex}>
|
||||||
expandedInventoryTypes,
|
<div className="inventory-mobile-variant-heading">{tab === 'region' ? <span className="inventory-tag inventory-tag-type">{variants[0].type || '未分类'}</span> : <InventoryModelTags row={variants[0]} hideType />}</div>
|
||||||
toggleInventoryType,
|
{variants.map((row, index) => <div className="inventory-mobile-row" key={index}>
|
||||||
setVehicleSelection: setShowPlateNumbers,
|
<div>{tab === 'region' ? <InventoryModelTags row={row} hideType /> : <><span className="inventory-place-region">{row.region}</span><strong>{row.city}</strong></>}</div>
|
||||||
}: InventoryMobileListProps) {
|
<InventoryQuantity quantity={row.quantity} label={`${row.city} · ${row.brand} · ${row.model}`} onClick={() => selectInventoryRow(row, select)} />
|
||||||
return (
|
{(row.abnormal || row.pending) ? <div className="inventory-mobile-status"><InventoryStatusCounts rows={[row]} /></div> : null}
|
||||||
<div className="lg:hidden p-3 space-y-3">
|
</div>)}
|
||||||
{inventoryTab === 'region' ? (
|
</section>)}
|
||||||
Object.entries(inventoryByRegion).map(([region, cities]) => (
|
</section>)}
|
||||||
<div key={region} className="border border-slate-100 rounded overflow-hidden">
|
</section>)}
|
||||||
<div
|
</div>}
|
||||||
className="bg-slate-50 p-3 flex flex-wrap gap-2 justify-between items-center cursor-pointer"
|
</article>)}
|
||||||
onClick={() => toggleInventoryRegion(region)}
|
</div>;
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
|
||||||
<span className="text-xs font-bold text-slate-700">{region}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] font-bold text-blue-600">
|
|
||||||
{Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
|
|
||||||
</span>
|
|
||||||
<InventoryStatusCounts rows={Object.values(cities).flat()} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expandedInventoryRegions.has(region) && (
|
|
||||||
<div className="p-2 space-y-2 bg-white">
|
|
||||||
{Object.entries(cities).map(([city, stats]) => (
|
|
||||||
<div key={city} className="border-l-2 border-slate-200 pl-3 py-1">
|
|
||||||
<div className="text-[10px] font-bold text-slate-500 mb-2">{city}</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{stats.map((stat, idx) => (
|
|
||||||
<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">
|
|
||||||
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
|
|
||||||
<span className="text-slate-700 font-medium">{stat.model}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: stat.model, brand: stat.brand, vehicleType: stat.type, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` })}
|
|
||||||
className="font-bold text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
{stat.quantity} 台
|
|
||||||
</button>
|
|
||||||
{(stat.abnormal || stat.pending) ? <div className="w-full"><InventoryStatusCounts rows={[stat]} /></div> : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
Object.entries(inventoryByModel).map(([type, models]) => (
|
|
||||||
<div key={type} className="border border-slate-100 rounded overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="bg-slate-50 p-3 flex flex-wrap gap-2 justify-between items-center cursor-pointer"
|
|
||||||
onClick={() => toggleInventoryType(type)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
|
||||||
<span className="text-xs font-bold text-slate-700">{type}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] font-bold text-blue-600">
|
|
||||||
{Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
|
|
||||||
</span>
|
|
||||||
<InventoryStatusCounts rows={Object.values(models).flat()} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expandedInventoryTypes.has(type) && (
|
|
||||||
<div className="p-2 space-y-2 bg-white">
|
|
||||||
{Object.entries(models).map(([model, stats]) => (
|
|
||||||
<div key={model} className="border-l-2 border-slate-200 pl-3 py-1">
|
|
||||||
<div className="text-[10px] font-bold text-slate-500 mb-2">{model}</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{stats.map((stat, idx) => (
|
|
||||||
<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">
|
|
||||||
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
|
|
||||||
<span className="text-slate-700 font-medium">{stat.region} / {stat.city}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: stat.model, brand: stat.brand, vehicleType: stat.type, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` })}
|
|
||||||
className="font-bold text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
{stat.quantity} 台
|
|
||||||
</button>
|
|
||||||
{(stat.abnormal || stat.pending) ? <div className="w-full"><InventoryStatusCounts rows={[stat]} /></div> : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { RegionalInventoryStats } from '../../types';
|
||||||
|
import { inventoryModelTags } from './inventory-presentation';
|
||||||
|
|
||||||
|
export function InventoryModelTags({ row, hideType = false }: { row: Pick<RegionalInventoryStats, 'type' | 'model' | 'batch' | 'brand'>; hideType?: boolean }) {
|
||||||
|
const tags = inventoryModelTags(row).filter(tag => !hideType || tag.kind !== 'type');
|
||||||
|
return <span className="inventory-model-tags">
|
||||||
|
{tags.length ? tags.map(tag => <span key={`${tag.kind}-${tag.value}`} className={`inventory-tag inventory-tag-${tag.kind}`}>
|
||||||
|
<span className="sr-only">{tag.label}:</span>{tag.value}
|
||||||
|
</span>) : '—'}
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
@@ -1,211 +1,121 @@
|
|||||||
import { InventoryStatusCounts } from './InventoryStatusCounts';
|
import { useId, useRef, useState } from 'react';
|
||||||
import { Filter } from 'lucide-react';
|
import { ArrowUpRight, Boxes, ChevronDown, ChevronsDownUp, ChevronsUpDown, Filter, MapPin, SearchX, Truck, X } from 'lucide-react';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
|
||||||
import { InventoryDesktopTable } from './InventoryDesktopTable';
|
import { InventoryDesktopTable } from './InventoryDesktopTable';
|
||||||
import { InventoryMobileList } from './InventoryMobileList';
|
import { InventoryMobileList } from './InventoryMobileList';
|
||||||
|
import { inventoryGroups, inventorySelection, inventoryTotal } from './inventory-presentation';
|
||||||
|
import type { InventoryFilters } from '../../model';
|
||||||
import type { InventoryStatisticsSectionProps } from './types';
|
import type { InventoryStatisticsSectionProps } from './types';
|
||||||
|
import './inventory-statistics.css';
|
||||||
|
|
||||||
export function InventoryStatisticsSection({
|
const emptyFilters: InventoryFilters = { region: '', city: '', brand: '', type: '', model: '' };
|
||||||
inventoryFilters,
|
const filterLabels: Record<keyof InventoryFilters, string> = { region: '区域', city: '城市', brand: '品牌', type: '车型', model: '型号 / 批次' };
|
||||||
setInventoryFilters,
|
|
||||||
draftInventoryFilters,
|
export function InventoryStatisticsSection(props: InventoryStatisticsSectionProps) {
|
||||||
setDraftInventoryFilters,
|
const {
|
||||||
isInventoryFilterOpen,
|
inventoryFilters: filters, setInventoryFilters: setFilters,
|
||||||
setIsInventoryFilterOpen,
|
draftInventoryFilters: draft, setDraftInventoryFilters: setDraft,
|
||||||
uniqueInventoryRegions,
|
isInventoryFilterOpen: filterOpen, setIsInventoryFilterOpen: setFilterOpen,
|
||||||
uniqueInventoryCities,
|
filteredInventoryStats: rows, inventoryTab: tab, setInventoryTab: setTab,
|
||||||
uniqueInventoryBrands,
|
setVehicleSelection: select,
|
||||||
uniqueInventoryTypes,
|
} = props;
|
||||||
uniqueInventoryModelsForType,
|
const id = useId();
|
||||||
filteredInventoryStats,
|
const filterTrigger = useRef<HTMLButtonElement>(null);
|
||||||
inventoryTab,
|
const [sort, setSort] = useState<'default' | 'quantity'>('default');
|
||||||
setInventoryTab,
|
const total = inventoryTotal(rows);
|
||||||
inventoryByRegion,
|
const abnormal = rows.reduce((sum, row) => sum + (row.abnormal || 0), 0);
|
||||||
expandedInventoryRegions,
|
const pending = rows.reduce((sum, row) => sum + (row.pending || 0), 0);
|
||||||
toggleInventoryRegion,
|
const activeFilters = (Object.keys(filters) as (keyof InventoryFilters)[]).filter(key => filters[key]);
|
||||||
inventoryByModel,
|
const groups = inventoryGroups(tab === 'region' ? props.inventoryByRegion : props.inventoryByModel, sort, tab);
|
||||||
expandedInventoryTypes,
|
const expanded = tab === 'region' ? props.expandedInventoryRegions : props.expandedInventoryTypes;
|
||||||
toggleInventoryType,
|
const toggle = tab === 'region' ? props.toggleInventoryRegion : props.toggleInventoryType;
|
||||||
setVehicleSelection: setShowPlateNumbers,
|
const allExpanded = groups.length > 0 && groups.every(group => expanded.has(group.name));
|
||||||
}: InventoryStatisticsSectionProps) {
|
const closeFilters = () => { setFilterOpen(false); filterTrigger.current?.focus(); };
|
||||||
const listProps = {
|
const resetFilters = () => { setFilters({ ...emptyFilters }); setDraft({ ...emptyFilters }); };
|
||||||
inventoryTab,
|
const options = {
|
||||||
inventoryByRegion,
|
region: props.uniqueInventoryRegions, city: props.uniqueInventoryCities,
|
||||||
expandedInventoryRegions,
|
brand: props.uniqueInventoryBrands, type: props.uniqueInventoryTypes,
|
||||||
toggleInventoryRegion,
|
model: props.uniqueInventoryModelsForType,
|
||||||
inventoryByModel,
|
|
||||||
expandedInventoryTypes,
|
|
||||||
toggleInventoryType,
|
|
||||||
setVehicleSelection: setShowPlateNumbers,
|
|
||||||
};
|
};
|
||||||
|
const listProps = { groups, tab, expanded, toggle, total, filters, select };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="inventory-statistics" className="bg-white rounded-2xl border border-gray-100 shadow-sm mb-6 overflow-hidden min-h-[420px]">
|
<section data-testid="inventory-statistics" className="inventory-statistics" aria-labelledby={`${id}-heading`}>
|
||||||
<div className="p-3 sm:p-4 border-b border-gray-50 bg-white flex items-center justify-between relative">
|
<header className="inventory-heading">
|
||||||
<div className="flex items-center gap-3">
|
<div className="inventory-title">
|
||||||
<div className="w-1.5 h-6 bg-blue-600 rounded-full"></div>
|
<span className="inventory-title-icon"><Boxes size={20} aria-hidden="true" /></span>
|
||||||
<div className="flex flex-col">
|
<div><h2 id={`${id}-heading`}>库存统计</h2><p>从区域与车型查看库存分布,点击数量查看车辆明细</p></div>
|
||||||
<h2 className="text-lg font-bold text-gray-800 leading-tight">库存统计</h2>
|
|
||||||
<span className="text-[10px] text-gray-400 font-medium">实时更新库存数据</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button ref={filterTrigger} className="inventory-control" aria-expanded={filterOpen} aria-controls={`${id}-filters`}
|
||||||
|
onClick={() => { if (!filterOpen) setDraft({ ...filters }); setFilterOpen(!filterOpen); }}>
|
||||||
|
<Filter size={16} aria-hidden="true" /> 筛选{activeFilters.length > 0 && <span className="inventory-filter-count">{activeFilters.length}</span>}
|
||||||
|
<ChevronDown size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="flex items-center gap-6 ml-auto pr-2">
|
<div className="inventory-metrics" data-testid="inventory-status-summary">
|
||||||
<div className="flex flex-col items-end cursor-pointer hover:opacity-80 transition-opacity"
|
<button className="inventory-metric inventory-metric-primary" disabled={!total}
|
||||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
|
onClick={() => select(inventorySelection(filters, activeFilters.length ? '库存统计 · 筛选结果' : '库存统计 · 全部库存'))}>
|
||||||
<span className="text-[10px] text-gray-400 font-bold tracking-wider uppercase mb-0.5">总库存数据</span>
|
<span>{activeFilters.length ? '筛选后库存' : '库存总数'}<ArrowUpRight size={16} aria-hidden="true" /></span>
|
||||||
<span className="text-2xl font-black text-gray-900 leading-none">
|
<strong>{total.toLocaleString()}<small>台</small></strong>
|
||||||
{filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)}
|
</button>
|
||||||
<span className="text-xs font-bold text-gray-400 ml-1">台</span>
|
<button className="inventory-metric" disabled={!pending}
|
||||||
</span>
|
onClick={() => select(inventorySelection(filters, '库存统计 · 待交车', { category: 'Pending' }))}>
|
||||||
</div>
|
<span>其中待交车<ArrowUpRight size={16} aria-hidden="true" /></span>
|
||||||
|
<strong>{pending.toLocaleString()}<small>台</small></strong>
|
||||||
<div className="relative">
|
</button>
|
||||||
<button
|
<button className="inventory-metric inventory-metric-warning" disabled={!abnormal}
|
||||||
onClick={() => { if (!isInventoryFilterOpen) setDraftInventoryFilters({...inventoryFilters}); setIsInventoryFilterOpen(!isInventoryFilterOpen); }}
|
onClick={() => select(inventorySelection(filters, '库存统计 · 异动', { category: 'Abnormal' }))}>
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
<span>其中异动<ArrowUpRight size={16} aria-hidden="true" /></span>
|
||||||
isInventoryFilterOpen || (inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model)
|
<strong>{abnormal.toLocaleString()}<small>台</small></strong>
|
||||||
? 'bg-blue-600 text-white shadow-md'
|
</button>
|
||||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Filter size={14} />
|
|
||||||
<span>筛选</span>
|
|
||||||
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
|
|
||||||
<span className="w-2 h-2 bg-white rounded-full animate-pulse"></span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{isInventoryFilterOpen && (
|
|
||||||
<>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-xs font-bold text-slate-800">库存统计 - 数据筛选</h3>
|
|
||||||
<button onClick={() => setDraftInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[10px] text-blue-500 hover:underline">重置所有</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 text-left">
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] text-slate-400 block mb-1">区域</label>
|
|
||||||
<select value={draftInventoryFilters.region} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, region: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
|
||||||
<option value="">全部区域</option>
|
|
||||||
{uniqueInventoryRegions.map(r => <option key={r} value={r}>{r}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] text-slate-400 block mb-1">城市</label>
|
|
||||||
<select value={draftInventoryFilters.city} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, city: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
|
||||||
<option value="">全部城市</option>
|
|
||||||
{uniqueInventoryCities.map(c => <option key={c} value={c}>{c}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] text-slate-400 block mb-1">品牌</label>
|
|
||||||
<select value={draftInventoryFilters.brand} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, brand: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
|
||||||
<option value="">全部品牌</option>
|
|
||||||
{uniqueInventoryBrands.map(b => <option key={b} value={b}>{b}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] text-slate-400 block mb-1">车型</label>
|
|
||||||
<select value={draftInventoryFilters.type} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, type: e.target.value, model: ''})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
|
||||||
<option value="">全部车型</option>
|
|
||||||
{uniqueInventoryTypes.map(t => <option key={t} value={t}>{t}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] text-slate-400 block mb-1">批次</label>
|
|
||||||
<select value={draftInventoryFilters.model} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, model: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
|
||||||
<option value="">全部批次</option>
|
|
||||||
{uniqueInventoryModelsForType.map(m => <option key={m} value={m}>{m}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => { setInventoryFilters({...draftInventoryFilters}); setIsInventoryFilterOpen(false); }} className="w-full mt-4 py-2 bg-blue-600 text-white rounded-lg text-xs font-bold hover:bg-blue-700 transition-colors">确认</button>
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="inventory-summary-note"><span>{new Set(rows.map(row => row.region)).size} 个区域 · {new Set(rows.map(row => row.city)).size} 个城市</span><span>待交车、异动已包含在库存总数中</span></div>
|
||||||
|
|
||||||
{filteredInventoryStats.some(row => (row.abnormal || 0) > 0 || (row.pending || 0) > 0) && (
|
{filterOpen && <form id={`${id}-filters`} className="inventory-filters" aria-label="库存筛选"
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-slate-100 px-4 py-2" data-testid="inventory-status-summary">
|
onKeyDown={event => { if (event.key === 'Escape') { event.preventDefault(); closeFilters(); } }}
|
||||||
<InventoryStatusCounts rows={filteredInventoryStats} />
|
onSubmit={event => { event.preventDefault(); setFilters({ ...draft }); closeFilters(); }}>
|
||||||
<span className="text-[10px] text-gray-400">已包含在库存总数中</span>
|
<div className="inventory-filter-heading"><strong>筛选库存</strong><button type="button" className="inventory-icon-button" aria-label="关闭库存筛选" onClick={closeFilters}><X size={18} aria-hidden="true" /></button></div>
|
||||||
|
<div className="inventory-filter-fields">
|
||||||
|
{(Object.keys(filterLabels) as (keyof InventoryFilters)[]).map(key => <label key={key}>
|
||||||
|
{filterLabels[key]}
|
||||||
|
<select value={draft[key]} onChange={event => setDraft({ ...draft, [key]: event.target.value, ...(key === 'type' ? { model: '' } : {}), ...(key === 'region' ? { city: '' } : {}) })}>
|
||||||
|
<option value="">全部{filterLabels[key]}</option>
|
||||||
|
{options[key].filter(Boolean).map(value => <option key={value} value={value}>{value}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="inventory-filter-actions">
|
||||||
|
<button type="button" className="inventory-text-button" onClick={() => setDraft({ ...emptyFilters })}>重置条件</button>
|
||||||
|
<button type="button" className="inventory-control" onClick={closeFilters}>取消</button>
|
||||||
|
<button type="submit" className="inventory-control inventory-control-primary">应用筛选</button>
|
||||||
|
</div>
|
||||||
|
</form>}
|
||||||
|
|
||||||
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
|
{activeFilters.length > 0 && <div className="inventory-active-filters" aria-label="已应用的库存筛选">
|
||||||
<div className="flex bg-gray-200/50 p-1 rounded-lg w-fit shadow-inner">
|
<span>已筛选</span>
|
||||||
<button
|
{activeFilters.map(key => <button key={key} onClick={() => setFilters({ ...filters, [key]: '', ...(key === 'type' ? { model: '' } : {}), ...(key === 'region' ? { city: '' } : {}) })}
|
||||||
onClick={() => setInventoryTab('region')}
|
aria-label={`移除${filterLabels[key]}筛选:${filters[key]}`}>
|
||||||
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
|
{filterLabels[key]}:{filters[key]}<X size={13} aria-hidden="true" />
|
||||||
inventoryTab === 'region' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
</button>)}
|
||||||
}`}
|
<button className="inventory-clear" onClick={resetFilters}>清除全部</button>
|
||||||
>
|
</div>}
|
||||||
按区域
|
|
||||||
</button>
|
<div className="inventory-toolbar">
|
||||||
<button
|
<div className="inventory-view-switch" role="group" aria-label="库存分组方式">
|
||||||
onClick={() => setInventoryTab('model')}
|
<button aria-pressed={tab === 'region'} onClick={() => setTab('region')}><MapPin size={15} aria-hidden="true" />按区域</button>
|
||||||
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
|
<button aria-pressed={tab === 'model'} onClick={() => setTab('model')}><Truck size={15} aria-hidden="true" />按车型</button>
|
||||||
inventoryTab === 'model' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
</div>
|
||||||
}`}
|
<div className="inventory-toolbar-actions">
|
||||||
>
|
<label className="inventory-sort"><span className="sr-only">库存分组排序</span><select value={sort} onChange={event => setSort(event.target.value as typeof sort)}><option value="default">默认排序</option><option value="quantity">库存从多到少</option></select></label>
|
||||||
按车型
|
<button className="inventory-text-button" disabled={!groups.length} onClick={() => groups.forEach(group => { if (expanded.has(group.name) === allExpanded) toggle(group.name); })}>
|
||||||
|
{allExpanded ? <ChevronsDownUp size={16} aria-hidden="true" /> : <ChevronsUpDown size={16} aria-hidden="true" />}{allExpanded ? '收起全部' : '展开全部'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Filters Bar */}
|
{groups.length ? <><InventoryDesktopTable {...listProps} /><InventoryMobileList {...listProps} /></> :
|
||||||
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
|
<div className="inventory-empty"><SearchX size={32} aria-hidden="true" /><h3>{activeFilters.length ? '没有符合条件的库存' : '暂无库存数据'}</h3><p>{activeFilters.length ? '试试减少筛选条件,或清除筛选查看全部库存。' : '当前所属公司范围内暂无库存记录。'}</p>{activeFilters.length > 0 && <button className="inventory-control" onClick={resetFilters}>清除筛选</button>}</div>}
|
||||||
<div className="px-4 py-2 border-b border-gray-100 flex flex-wrap gap-2 items-center">
|
<footer className="inventory-footer"><span role="status" aria-live="polite">共 {groups.length} 个{tab === 'region' ? '区域' : '车型'} · {rows.length} 条记录 · {total.toLocaleString()} 台库存</span><span>占比基于当前筛选结果 · 单位:台</span></footer>
|
||||||
{inventoryFilters.region && (
|
</section>
|
||||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
|
||||||
区域: {inventoryFilters.region}
|
|
||||||
<button onClick={() => setInventoryFilters({...inventoryFilters, region: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{inventoryFilters.city && (
|
|
||||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
|
||||||
城市: {inventoryFilters.city}
|
|
||||||
<button onClick={() => setInventoryFilters({...inventoryFilters, city: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{inventoryFilters.brand && (
|
|
||||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
|
||||||
品牌: {inventoryFilters.brand}
|
|
||||||
<button onClick={() => setInventoryFilters({...inventoryFilters, brand: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{inventoryFilters.type && (
|
|
||||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
|
||||||
车型: {inventoryFilters.type}
|
|
||||||
<button onClick={() => setInventoryFilters({...inventoryFilters, type: '', model: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{inventoryFilters.model && (
|
|
||||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
|
||||||
批次: {inventoryFilters.model}
|
|
||||||
<button onClick={() => setInventoryFilters({...inventoryFilters, model: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button onClick={() => setInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[11px] text-red-500 font-bold ml-auto hover:text-red-600">清除</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Desktop View Table */}
|
|
||||||
<InventoryDesktopTable {...listProps} />
|
|
||||||
|
|
||||||
{/* Mobile View */}
|
|
||||||
<InventoryMobileList {...listProps} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<RegionalInventoryStats, 'type' | 'model' | 'batch'> & Partial<Pick<RegionalInventoryStats, 'brand'>>) {
|
||||||
|
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> = {}): 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<string, { brand: string; rows: RegionalInventoryStats[]; variants: Map<string, RegionalInventoryStats[]> }>();
|
||||||
|
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<string, Record<string, RegionalInventoryStats[]>>, 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;
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -27,3 +27,11 @@ test('completed movement retains its actual time and missing records stay distin
|
|||||||
assert.equal(completed.abnormalMove?.work, null);
|
assert.equal(completed.abnormalMove?.work, null);
|
||||||
assert.equal(transformRow(row).abnormalMove, 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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ export function transformRow(row: VehicleRow): Vehicle {
|
|||||||
type: ({ 1: '维修', 2: '保养', 3: '年审', 4: '其他' } as Record<number, string>)[Number(row.abnormal_move_type)] || '未提供',
|
type: ({ 1: '维修', 2: '保养', 3: '年审', 4: '其他' } as Record<number, string>)[Number(row.abnormal_move_type)] || '未提供',
|
||||||
status: Number(row.abnormal_move_status) === 5 ? '已完成' : '待结束',
|
status: Number(row.abnormal_move_status) === 5 ? '已完成' : '待结束',
|
||||||
work: row.abnormal_move_work?.trim() || null,
|
work: row.abnormal_move_work?.trim() || null,
|
||||||
|
applicant: row.abnormal_move_applicant?.trim() || null,
|
||||||
targetCompletionTime: row.abnormal_move_target_time || null,
|
targetCompletionTime: row.abnormal_move_target_time || null,
|
||||||
actualCompletionTime: row.abnormal_move_actual_time || null,
|
actualCompletionTime: row.abnormal_move_actual_time || null,
|
||||||
// The source has no confirmed department, assignee or operating-driver fields.
|
// The source has no confirmed department, assignee or operating-driver fields.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ 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,
|
CAST(am.id AS CHAR) AS abnormal_move_id,
|
||||||
am.move_type AS abnormal_move_type,
|
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,
|
CAST(pdv.id AS CHAR) AS pending_delivery_id,
|
||||||
pdts.contract_code AS pending_contract_no,
|
pdts.contract_code AS pending_contract_no,
|
||||||
pdts.customer_name AS pending_customer_name,
|
pdts.customer_name AS pending_customer_name,
|
||||||
@@ -147,6 +148,7 @@ LEFT JOIN vehicle_abnormal_move_record amr ON amr.id = (
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
)
|
)
|
||||||
LEFT JOIN vehicle_abnormal_move am ON am.id = amr.move_id
|
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}
|
${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'`;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface VehicleRow {
|
|||||||
abnormal_move_type?: number | null;
|
abnormal_move_type?: number | null;
|
||||||
abnormal_move_status?: number | null;
|
abnormal_move_status?: number | null;
|
||||||
abnormal_move_work?: string | null;
|
abnormal_move_work?: string | null;
|
||||||
|
abnormal_move_applicant?: string | null;
|
||||||
abnormal_move_target_time?: string | null;
|
abnormal_move_target_time?: string | null;
|
||||||
abnormal_move_actual_time?: string | null;
|
abnormal_move_actual_time?: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export interface AbnormalMoveDetail {
|
|||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
work: string | null;
|
work: string | null;
|
||||||
|
/** Applicant displayed from the movement creator account, not the vehicle owner. */
|
||||||
|
applicant?: string | null;
|
||||||
targetCompletionTime: string | null;
|
targetCompletionTime: string | null;
|
||||||
actualCompletionTime: string | null;
|
actualCompletionTime: string | null;
|
||||||
initiatingDepartment: string | null;
|
initiatingDepartment: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user