feat(assets): add dimension analysis and authorized user previews
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
Refactor asset drilldowns with combined dimension filters, parent summaries and progressive loading. Add the hidden /who read-only permission preview with scoped authorization, identity mapping and audit logging. Refine inventory counts and AI问数 presentation.
This commit is contained in:
@@ -1,100 +1,176 @@
|
||||
import { Fragment, type Dispatch, type SetStateAction } from 'react';
|
||||
import { ArrowUpRight, ChevronRight } from 'lucide-react';
|
||||
import { InventoryStatusCounts } from './InventoryStatusCounts';
|
||||
import { InventoryModelTags } from './InventoryModelTags';
|
||||
import { inventoryBrandGroups, inventoryRegionRows, inventorySelection, inventorySubtotalSelection, inventoryTotal, type InventoryDisplayGroup } from './inventory-presentation';
|
||||
import { inventoryTypeLabel, type InventoryFilters, type VehicleModalSelection } from '../../model';
|
||||
import { inventorySelection, type InventoryDisplayGroup } from './inventory-presentation';
|
||||
import type { InventoryFilters } from '../../model';
|
||||
import type { RegionalInventoryStats } from '../../types';
|
||||
import { inventorySubtotalSelection } from './inventory-presentation';
|
||||
import React from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { InventoryStatisticsSectionProps } from './types';
|
||||
|
||||
export interface InventoryListProps {
|
||||
groups: InventoryDisplayGroup[];
|
||||
tab: 'region' | 'model';
|
||||
expanded: Set<string>;
|
||||
toggle: (name: string) => void;
|
||||
total: number;
|
||||
filters: InventoryFilters;
|
||||
select: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||
type InventoryDesktopTableProps = Pick<
|
||||
InventoryStatisticsSectionProps,
|
||||
| 'inventoryTab'
|
||||
| 'inventoryByRegion'
|
||||
| 'expandedInventoryRegions'
|
||||
| 'toggleInventoryRegion'
|
||||
| 'inventoryByModel'
|
||||
| 'expandedInventoryTypes'
|
||||
| 'toggleInventoryType'
|
||||
| 'setVehicleSelection'
|
||||
>;
|
||||
|
||||
export function InventoryDesktopTable({
|
||||
inventoryTab,
|
||||
inventoryByRegion,
|
||||
expandedInventoryRegions,
|
||||
toggleInventoryRegion,
|
||||
inventoryByModel,
|
||||
expandedInventoryTypes,
|
||||
toggleInventoryType,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: InventoryDesktopTableProps) {
|
||||
return (
|
||||
<div className="hidden lg:block overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<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>
|
||||
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '车型' : '所在区域/城市'}</th>
|
||||
<th className="p-3 font-semibold text-center w-32">库存数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-xs">
|
||||
{inventoryTab === 'region' ? (
|
||||
Object.entries(inventoryByRegion).map(([region, cities]) => (
|
||||
<React.Fragment key={region}>
|
||||
<tr
|
||||
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||
onClick={() => toggleInventoryRegion(region)}
|
||||
>
|
||||
<td colSpan={4} className="p-3 font-bold text-slate-700">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span>{region}</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(inventorySubtotalSelection(Object.values(cities).flat(), `库存统计 - ${region}`));
|
||||
}}>
|
||||
(共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
|
||||
</span>
|
||||
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={Object.values(cities).flat()} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<AnimatePresence>
|
||||
{expandedInventoryRegions.has(region) && Object.entries(cities).map(([city, stats]) => (
|
||||
<React.Fragment key={city}>
|
||||
{stats.map((stat, idx) => (
|
||||
<motion.tr
|
||||
key={`${city}-${stat.model}-${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">{city}</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.model}</td>
|
||||
<td className="p-3 text-center font-bold text-blue-600">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers(inventorySubtotalSelection([stat], `库存统计 - ${stat.model} - ${stat.city}`));
|
||||
}}
|
||||
className="text-blue-600 hover:underline font-bold"
|
||||
>
|
||||
{stat.quantity}
|
||||
</button>
|
||||
{((stat.pending ?? 0) > 0 || (stat.abnormal ?? 0) > 0) && (
|
||||
<div className="mt-1"><InventoryStatusCounts onSelect={setShowPlateNumbers} 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 flex-wrap 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(inventorySubtotalSelection(Object.values(models).flat(), `库存统计 - ${type}`));
|
||||
}}>
|
||||
(共 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
|
||||
</span>
|
||||
<InventoryStatusCounts onSelect={setShowPlateNumbers} 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(inventorySubtotalSelection([stat], `库存统计 - ${stat.model} - ${stat.city}`));
|
||||
}}
|
||||
className="text-blue-600 hover:underline font-bold"
|
||||
>
|
||||
{stat.quantity}
|
||||
</button>
|
||||
{((stat.pending ?? 0) > 0 || (stat.abnormal ?? 0) > 0) && (
|
||||
<div className="mt-1"><InventoryStatusCounts onSelect={setShowPlateNumbers} rows={[stat]} /></div>
|
||||
)}
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InventoryQuantity({ quantity, label, onClick }: { quantity: number; label: string; onClick: () => void }) {
|
||||
return <button className="inventory-quantity" disabled={!quantity} onClick={onClick} aria-label={`${label},库存 ${quantity} 台,查看车辆明细`}>
|
||||
{quantity.toLocaleString()}<ArrowUpRight size={14} aria-hidden="true" />
|
||||
</button>;
|
||||
}
|
||||
|
||||
export function selectInventoryRow(row: RegionalInventoryStats, select: InventoryListProps['select']) {
|
||||
export function selectInventoryRow(row: RegionalInventoryStats, select: InventoryStatisticsSectionProps['setVehicleSelection']) {
|
||||
select(inventorySelection(row, `库存统计 · ${row.city} · ${row.model}`, { vehicleType: row.type }));
|
||||
}
|
||||
|
||||
export function selectInventoryGroup(group: InventoryDisplayGroup, { tab, filters, select }: Pick<InventoryListProps, 'tab' | 'filters' | 'select'>) {
|
||||
export function selectInventoryGroup(group: InventoryDisplayGroup, { tab, filters, select }: { tab: 'region' | 'model'; filters: InventoryFilters; select: InventoryStatisticsSectionProps['setVehicleSelection'] }) {
|
||||
select(inventorySelection(tab === 'model' ? { ...filters, type: group.name } : filters, `库存统计 · ${group.name}`, tab === 'region'
|
||||
? { location: filters.city || group.name }
|
||||
: {}));
|
||||
}
|
||||
|
||||
export function InventoryShare({ quantity, total }: { quantity: number; total: number }) {
|
||||
const percent = total ? quantity / total * 100 : 0;
|
||||
return <div className="inventory-share"><span className="inventory-share-track" aria-hidden="true"><span style={{ width: `${percent}%` }} /></span><span>{percent.toFixed(1)}%</span></div>;
|
||||
}
|
||||
|
||||
function InventorySubtotal({ rows, label, select }: { rows: RegionalInventoryStats[]; label: string; select: InventoryListProps['select'] }) {
|
||||
return <div className={`inventory-subtotal${rows.length === 1 ? ' inventory-subtotal-inline' : ''}`} aria-label={`${label}合计 ${inventoryTotal(rows)} 台`}>
|
||||
<button className="inventory-subtotal-button" disabled={!inventoryTotal(rows)} aria-label={`${label}合计 ${inventoryTotal(rows)} 台,查看车辆明细`} onClick={() => select(inventorySubtotalSelection(rows, label))}>合计 <strong>{inventoryTotal(rows).toLocaleString()}</strong> 台<ArrowUpRight size={12} aria-hidden="true" /></button>
|
||||
<InventoryStatusCounts rows={rows} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function InventoryDetailRows({ rows, city, tab, total, select }: Pick<InventoryListProps, 'tab' | 'total' | 'select'> & { rows: RegionalInventoryStats[]; city?: string }) {
|
||||
const brands = inventoryBrandGroups(rows, tab === 'region' ? 'type' : 'model');
|
||||
return brands.map((brand, brandIndex) => brand.variants.map((variants, variantIndex) =>
|
||||
(tab === 'model' ? inventoryRegionRows(variants) : variants.map(row => ({ row, regionSpan: 0 }))).map(({ row, regionSpan }, index) =>
|
||||
<tr className="inventory-detail-row" key={`${brandIndex}-${variantIndex}-${index}`}>
|
||||
{tab === 'region' && brandIndex === 0 && variantIndex === 0 && index === 0 && <th rowSpan={rows.length} scope="rowgroup" className="inventory-merged-cell inventory-city-cell">{city || '未分配'}<InventorySubtotal select={select} rows={rows} label={city || '未分配'} /></th>}
|
||||
{variantIndex === 0 && index === 0 && <td rowSpan={brand.rows.length} className="inventory-merged-cell">{brand.brand || '—'}<InventorySubtotal select={select} rows={brand.rows} label={brand.brand || '未分配品牌'} /></td>}
|
||||
{index === 0 && <td rowSpan={variants.length} className="inventory-merged-cell">
|
||||
{tab === 'region' ? <span className="inventory-tag inventory-tag-type">{inventoryTypeLabel(row.type) || '未分类'}</span> : <InventoryModelTags row={row} hideType />}
|
||||
<InventorySubtotal select={select} rows={variants} label={tab === 'region' ? inventoryTypeLabel(row.type) : row.model} />
|
||||
</td>}
|
||||
{tab === 'model' && regionSpan > 0 && <td rowSpan={regionSpan} className="inventory-merged-cell inventory-region-cell">{row.region || '未分配'}<InventorySubtotal select={select} rows={variants.filter(item => item.region === row.region)} label={row.region || '未分配'} /></td>}
|
||||
<td>{tab === 'region' ? <InventoryModelTags row={row} hideType /> : row.city || '未分配'}</td>
|
||||
<td className="inventory-number-column"><InventoryQuantity quantity={row.quantity} label={`${row.city} · ${row.brand} · ${row.model}`} onClick={() => selectInventoryRow(row, select)} /><InventoryStatusCounts rows={[row]} /></td>
|
||||
<td><InventoryShare quantity={row.quantity} total={total} /></td>
|
||||
</tr>
|
||||
)));
|
||||
}
|
||||
|
||||
export function InventoryDesktopTable(props: InventoryListProps) {
|
||||
const { groups, tab, expanded, toggle, total, select } = props;
|
||||
return <div className="inventory-desktop">
|
||||
<table className="inventory-table" data-group-by={tab}>
|
||||
<caption className="sr-only">按{tab === 'region' ? '区域' : '车型'}分组的库存统计,占比基于当前筛选库存总数</caption>
|
||||
<thead><tr>
|
||||
{tab === 'region' && <th scope="col">区域 / 城市</th>}
|
||||
<th scope="col">品牌</th>
|
||||
{tab === 'region' && <th scope="col">车型</th>}
|
||||
<th scope="col">类别</th>
|
||||
{tab === 'model' && <><th scope="col">区域</th><th scope="col">城市</th></>}
|
||||
<th scope="col" className="inventory-number-column">库存数量</th>
|
||||
<th scope="col" className="inventory-share-column">库存占比</th>
|
||||
</tr></thead>
|
||||
{groups.map(group => <Fragment key={group.name}><tbody>
|
||||
<tr className="inventory-group-row" data-expanded={expanded.has(group.name)}>
|
||||
<th scope="row" colSpan={4}>
|
||||
<div className="inventory-group-heading">
|
||||
<button className="inventory-expand" aria-expanded={expanded.has(group.name)} onClick={() => toggle(group.name)}>
|
||||
<ChevronRight size={16} aria-hidden="true" /><strong>{group.name || '未分配'}</strong><span>{group.sections.length} 个{tab === 'region' ? '城市' : '型号'}</span>
|
||||
</button>
|
||||
<InventoryStatusCounts rows={group.rows} />
|
||||
</div>
|
||||
</th>
|
||||
<td className="inventory-number-column"><InventoryQuantity quantity={group.quantity} label={group.name} onClick={() => selectInventoryGroup(group, props)} /></td>
|
||||
<td><InventoryShare quantity={group.quantity} total={total} /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{expanded.has(group.name) && (tab === 'region' ? group.sections : [[group.name, group.rows] as const]).map(([name, rows]) => <tbody key={name} className="inventory-detail-group">
|
||||
<InventoryDetailRows rows={rows} city={name} tab={tab} total={total} select={select} />
|
||||
</tbody>)}
|
||||
</Fragment>)}
|
||||
</table>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,77 +1,131 @@
|
||||
import { Fragment, useId, useRef, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, MapPin, Truck } from 'lucide-react';
|
||||
import { InventoryStatusCounts } from './InventoryStatusCounts';
|
||||
import { inventoryMobileDrilldown, inventoryMobileGroups, inventoryPage, inventorySubtotalSelection, inventoryTotal, type InventoryDisplayGroup } from './inventory-presentation';
|
||||
import { InventoryQuantity, InventoryShare, selectInventoryGroup, type InventoryListProps } from './InventoryDesktopTable';
|
||||
import { inventorySubtotalSelection } from './inventory-presentation';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type { InventoryStatisticsSectionProps } from './types';
|
||||
|
||||
function MobileGroupDetails({ group, tab, select }: Pick<InventoryListProps, 'tab' | 'select'> & { group: InventoryDisplayGroup }) {
|
||||
const [path, setPath] = useState<string[]>([]);
|
||||
const [requestedPage, setPage] = useState(0);
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
const view = inventoryMobileDrilldown(group.rows, tab, path);
|
||||
const labels = { region: '区域', city: '城市', type: '车型', variant: '品牌 / 型号' };
|
||||
const { page, pageCount, start, rows: pageGroups } = inventoryPage(view.groups, requestedPage);
|
||||
const activePath = view.parents.map(parent => parent.key);
|
||||
const navigate = (nextPath: string[]) => {
|
||||
setPath(nextPath);
|
||||
setPage(0);
|
||||
heading.current?.focus({ preventScroll: true });
|
||||
};
|
||||
const openVehicles = (rows: typeof group.rows, label: string) => select(inventorySubtotalSelection(rows, label));
|
||||
type InventoryMobileListProps = Pick<
|
||||
InventoryStatisticsSectionProps,
|
||||
| 'inventoryTab'
|
||||
| 'inventoryByRegion'
|
||||
| 'expandedInventoryRegions'
|
||||
| 'toggleInventoryRegion'
|
||||
| 'inventoryByModel'
|
||||
| 'expandedInventoryTypes'
|
||||
| 'toggleInventoryType'
|
||||
| 'setVehicleSelection'
|
||||
>;
|
||||
|
||||
return <div className="inventory-mobile-details">
|
||||
{view.parents.length > 0 && <nav className="inventory-mobile-breadcrumb" aria-label="库存下钻路径">
|
||||
<button onClick={() => navigate([])}>{group.name || '未分配'}</button>
|
||||
{view.parents.map((parent, index) => <Fragment key={parent.key}>
|
||||
<ChevronRight size={12} aria-hidden="true" />
|
||||
{index === view.parents.length - 1 ? <span aria-current="location">{parent.label}</span> :
|
||||
<button onClick={() => navigate(activePath.slice(0, index + 1))}>{parent.label}</button>}
|
||||
</Fragment>)}
|
||||
</nav>}
|
||||
<div className="inventory-mobile-detail-toolbar">
|
||||
<h3 ref={heading} tabIndex={-1}>{labels[view.dimension]}库存 <span>· {view.groups.length} 项</span></h3>
|
||||
{view.parents.length > 0 && <button className="inventory-text-button" onClick={() => navigate(activePath.slice(0, -1))}><ChevronLeft size={14} aria-hidden="true" />返回上一级</button>}
|
||||
export function InventoryMobileList({
|
||||
inventoryTab,
|
||||
inventoryByRegion,
|
||||
expandedInventoryRegions,
|
||||
toggleInventoryRegion,
|
||||
inventoryByModel,
|
||||
expandedInventoryTypes,
|
||||
toggleInventoryType,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: InventoryMobileListProps) {
|
||||
return (
|
||||
<div className="lg:hidden p-3 space-y-3">
|
||||
{inventoryTab === 'region' ? (
|
||||
Object.entries(inventoryByRegion).map(([region, cities]) => (
|
||||
<div key={region} className="border border-slate-100 rounded overflow-hidden">
|
||||
<div
|
||||
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleInventoryRegion(region)}
|
||||
>
|
||||
<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>
|
||||
<div className="flex flex-col items-end gap-1 text-right">
|
||||
<span className="text-[10px] font-bold text-blue-600">
|
||||
{Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
|
||||
</span>
|
||||
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={Object.values(cities).flat()} />
|
||||
</div>
|
||||
</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 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>
|
||||
<div className="flex flex-col items-end gap-1 text-right">
|
||||
<button
|
||||
onClick={() => setShowPlateNumbers(inventorySubtotalSelection([stat], `库存统计 - ${stat.model} - ${stat.city}`))}
|
||||
className="font-bold text-blue-600 hover:underline"
|
||||
>
|
||||
{stat.quantity} 台
|
||||
</button>
|
||||
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={[stat]} />
|
||||
</div>
|
||||
</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 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>
|
||||
<div className="flex flex-col items-end gap-1 text-right">
|
||||
<span className="text-[10px] font-bold text-blue-600">
|
||||
{Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
|
||||
</span>
|
||||
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={Object.values(models).flat()} />
|
||||
</div>
|
||||
</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 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>
|
||||
<div className="flex flex-col items-end gap-1 text-right">
|
||||
<button
|
||||
onClick={() => setShowPlateNumbers(inventorySubtotalSelection([stat], `库存统计 - ${stat.model} - ${stat.city}`))}
|
||||
className="font-bold text-blue-600 hover:underline"
|
||||
>
|
||||
{stat.quantity} 台
|
||||
</button>
|
||||
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={[stat]} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="inventory-mobile-list-heading"><span>{labels[view.dimension]}</span><span>库存(台)</span></div>
|
||||
<ul className="inventory-mobile-records" aria-label={`${labels[view.dimension]}库存列表`}>
|
||||
{pageGroups.map(item => <li key={item.key} className="inventory-mobile-drill-row">
|
||||
<button className="inventory-mobile-drill-label"
|
||||
aria-label={view.dimension === 'variant' ? `${item.label},查看车辆明细` : `查看${item.label}的${view.dimension === 'region' ? '城市' : view.dimension === 'city' && tab === 'region' ? '车型' : '品牌型号'}库存`}
|
||||
onClick={() => view.dimension === 'variant' ? openVehicles(item.rows, item.label) : navigate([...activePath, item.key])}>
|
||||
<span>{item.label}</span>
|
||||
{view.dimension !== 'variant' && <ChevronRight size={14} aria-hidden="true" />}
|
||||
</button>
|
||||
<InventoryQuantity quantity={item.quantity} label={item.label} onClick={() => openVehicles(item.rows, item.label)} />
|
||||
{item.rows.some(row => row.abnormal || row.pending) && <div className="inventory-mobile-status"><InventoryStatusCounts rows={item.rows} /></div>}
|
||||
</li>)}
|
||||
</ul>
|
||||
<div className="inventory-mobile-scope-total" role="status"><span>当前范围合计</span><strong>{inventoryTotal(view.rows).toLocaleString()} 台</strong></div>
|
||||
{pageCount > 1 && <nav className="inventory-mobile-pagination" aria-label={`${group.name}明细分页`}>
|
||||
<button disabled={page === 0} onClick={() => setPage(page - 1)}><ChevronLeft size={16} aria-hidden="true" />上一页</button>
|
||||
<span aria-live="polite">{start + 1}–{start + pageGroups.length} / {view.groups.length}</span>
|
||||
<button disabled={page === pageCount - 1} onClick={() => setPage(page + 1)}>下一页<ChevronRight size={16} aria-hidden="true" /></button>
|
||||
</nav>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function InventoryMobileList(props: InventoryListProps) {
|
||||
const { groups, tab, total } = props;
|
||||
const [activeGroup, setActiveGroup] = useState<string | null>(null);
|
||||
const id = useId();
|
||||
return <div className="inventory-mobile">
|
||||
<p className="inventory-mobile-hint">点击名称逐层查看,点击数量查看车辆</p>
|
||||
{groups.map((group, index) => <article key={group.name} className="inventory-mobile-group" data-expanded={activeGroup === group.name}>
|
||||
<div className="inventory-mobile-group-heading">
|
||||
<button className="inventory-expand" aria-expanded={activeGroup === group.name} aria-controls={`${id}-${index}`} onClick={() => setActiveGroup(activeGroup === group.name ? null : group.name)}>
|
||||
<span className="inventory-mobile-group-icon">{tab === 'region' ? <MapPin size={16} aria-hidden="true" /> : <Truck size={16} aria-hidden="true" />}</span><strong>{group.name || '未分配'}</strong><span>{inventoryMobileGroups(group.rows, tab === 'region' ? 'city' : 'region').length} 个{tab === 'region' ? '城市' : '区域'}</span><ChevronRight size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<InventoryQuantity quantity={group.quantity} label={group.name} onClick={() => selectInventoryGroup(group, props)} />
|
||||
</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>}
|
||||
<div id={`${id}-${index}`} hidden={activeGroup !== group.name}>
|
||||
{activeGroup === group.name && <MobileGroupDetails group={group} tab={tab} select={props.select} />}
|
||||
</div>
|
||||
</article>)}
|
||||
</div>;
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,121 +1,213 @@
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { ArrowUpRight, Boxes, ChevronDown, ChevronsDownUp, ChevronsUpDown, Filter, MapPin, SearchX, Truck, X } from 'lucide-react';
|
||||
import { useAuth } from '../../../../auth/useAuth';
|
||||
import { InventoryStatusCounts } from './InventoryStatusCounts';
|
||||
import { inventorySubtotalSelection } from './inventory-presentation';
|
||||
import { Filter } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/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';
|
||||
|
||||
const emptyFilters: InventoryFilters = { region: '', city: '', brand: '', type: '', model: '' };
|
||||
const filterLabels: Record<keyof InventoryFilters, string> = { 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<HTMLButtonElement>(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,
|
||||
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 { preview } = useAuth();
|
||||
const listProps = {
|
||||
inventoryTab,
|
||||
inventoryByRegion,
|
||||
expandedInventoryRegions,
|
||||
toggleInventoryRegion,
|
||||
inventoryByModel,
|
||||
expandedInventoryTypes,
|
||||
toggleInventoryType,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
};
|
||||
const listProps = { groups, tab, expanded, toggle, total, filters, select };
|
||||
|
||||
return (
|
||||
<section data-testid="inventory-statistics" className="inventory-statistics" aria-labelledby={`${id}-heading`}>
|
||||
<header className="inventory-heading">
|
||||
<div className="inventory-title">
|
||||
<span className="inventory-title-icon"><Boxes size={20} aria-hidden="true" /></span>
|
||||
<div><h2 id={`${id}-heading`}>库存统计</h2><p>按区域、车型核对库存分布</p></div>
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm mb-6 overflow-hidden min-h-[420px]">
|
||||
<div className="p-3 sm:p-4 border-b border-gray-50 bg-white flex items-center justify-between relative">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1.5 h-6 bg-blue-600 rounded-full"></div>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-lg font-bold text-gray-800 leading-tight">库存统计</h2>
|
||||
<span className="text-[10px] text-gray-400 font-medium">{preview ? '公共库存汇总 · 明细按当前用户权限查看' : '实时更新库存数据'}</span>
|
||||
</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="inventory-metrics" data-testid="inventory-status-summary">
|
||||
<button className="inventory-metric inventory-metric-primary" disabled={!total}
|
||||
onClick={() => select(inventorySelection(filters, activeFilters.length ? '库存统计 · 筛选结果' : '库存统计 · 全部库存'))}>
|
||||
<span>{activeFilters.length ? '筛选后库存' : '库存总数'}<ArrowUpRight size={16} aria-hidden="true" /></span>
|
||||
<strong>{total.toLocaleString()}<small>台</small></strong>
|
||||
</button>
|
||||
<button className="inventory-metric inventory-metric-pending" disabled={!pending}
|
||||
onClick={() => select(inventorySelection(filters, '库存统计 · 待交车', { category: 'Pending' }))}>
|
||||
<span>其中待交车<ArrowUpRight size={16} aria-hidden="true" /></span>
|
||||
<strong>{pending.toLocaleString()}<small>台</small></strong>
|
||||
</button>
|
||||
<button className="inventory-metric inventory-metric-warning" disabled={!abnormal}
|
||||
onClick={() => select(inventorySelection(filters, '库存统计 · 异动', { category: 'Abnormal' }))}>
|
||||
<span>其中异动<ArrowUpRight size={16} aria-hidden="true" /></span>
|
||||
<strong>{abnormal.toLocaleString()}<small>台</small></strong>
|
||||
</button>
|
||||
<div className="flex items-center gap-6 ml-auto pr-2">
|
||||
<div className="flex flex-col items-end cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => setShowPlateNumbers(inventorySubtotalSelection(filteredInventoryStats, '库存总数'))}>
|
||||
<span className="text-[10px] text-gray-400 font-bold tracking-wider uppercase mb-0.5">总库存数据</span>
|
||||
<span className="text-2xl font-black text-gray-900 leading-none">
|
||||
{filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)}
|
||||
<span className="text-xs font-bold text-gray-400 ml-1">台</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => { if (!isInventoryFilterOpen) setDraftInventoryFilters({...inventoryFilters}); setIsInventoryFilterOpen(!isInventoryFilterOpen); }}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
isInventoryFilterOpen || (inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model)
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: '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 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>
|
||||
|
||||
{filterOpen && <form id={`${id}-filters`} className="inventory-filters" aria-label="库存筛选"
|
||||
onKeyDown={event => { if (event.key === 'Escape') { event.preventDefault(); closeFilters(); } }}
|
||||
onSubmit={event => { event.preventDefault(); setFilters({ ...draft }); closeFilters(); }}>
|
||||
<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>)}
|
||||
{filteredInventoryStats.some(row => (row.pending ?? 0) > 0 || (row.abnormal ?? 0) > 0) && (
|
||||
<div className="border-b border-gray-100 px-4 py-2" data-testid="inventory-status-summary">
|
||||
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={filteredInventoryStats} />
|
||||
</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>}
|
||||
)}
|
||||
|
||||
{activeFilters.length > 0 && <div className="inventory-active-filters" aria-label="已应用的库存筛选">
|
||||
<span>已筛选</span>
|
||||
{activeFilters.map(key => <button key={key} onClick={() => setFilters({ ...filters, [key]: '', ...(key === 'type' ? { model: '' } : {}), ...(key === 'region' ? { city: '' } : {}) })}
|
||||
aria-label={`移除${filterLabels[key]}筛选:${filters[key]}`}>
|
||||
{filterLabels[key]}:{filters[key]}<X size={13} aria-hidden="true" />
|
||||
</button>)}
|
||||
<button className="inventory-clear" onClick={resetFilters}>清除全部</button>
|
||||
</div>}
|
||||
|
||||
<div className="inventory-toolbar">
|
||||
<div className="inventory-view-switch" role="group" aria-label="库存分组方式">
|
||||
<button aria-pressed={tab === 'region'} onClick={() => setTab('region')}><MapPin size={15} aria-hidden="true" />按区域</button>
|
||||
<button aria-pressed={tab === 'model'} onClick={() => setTab('model')}><Truck size={15} aria-hidden="true" />按车型</button>
|
||||
</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 inventory-expand-all" 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 ? '收起全部' : '展开全部'}
|
||||
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
|
||||
<div className="flex bg-gray-200/50 p-1 rounded-lg w-fit shadow-inner">
|
||||
<button
|
||||
onClick={() => setInventoryTab('region')}
|
||||
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
|
||||
inventoryTab === 'region' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
按区域
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInventoryTab('model')}
|
||||
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
|
||||
inventoryTab === 'model' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
按车型
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groups.length ? <><InventoryDesktopTable {...listProps} /><InventoryMobileList key={JSON.stringify([tab, filters])} {...listProps} /></> :
|
||||
<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>}
|
||||
<footer className="inventory-footer"><span role="status" aria-live="polite">共 {groups.length} 个{tab === 'region' ? '区域' : '车型'} · {rows.length} 条记录 · {total.toLocaleString()} 台库存</span><span>占比基于当前筛选结果 · 单位:台</span></footer>
|
||||
</section>
|
||||
{/* Active Filters Bar */}
|
||||
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
|
||||
<div className="px-4 py-2 border-b border-gray-100 flex flex-wrap gap-2 items-center">
|
||||
{inventoryFilters.region && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import type { RegionalInventoryStats } from '../../types';
|
||||
import type { VehicleModalSelection } from '../../model';
|
||||
import { inventorySubtotalSelection } from './inventory-presentation';
|
||||
|
||||
/** Status counts are subsets of these filtered inventory rows, never extra stock. */
|
||||
export function InventoryStatusCounts({ rows }: { rows: RegionalInventoryStats[] }) {
|
||||
export function InventoryStatusCounts({ rows, onSelect }: { rows: RegionalInventoryStats[]; onSelect: (selection: VehicleModalSelection) => void }) {
|
||||
const abnormal = rows.reduce((sum, row) => sum + (row.abnormal ?? 0), 0);
|
||||
const pending = rows.reduce((sum, row) => sum + (row.pending ?? 0), 0);
|
||||
if (abnormal === 0 && pending === 0) return null;
|
||||
return <span className="inline-flex flex-wrap gap-x-3 gap-y-1 text-[11px] font-normal" data-testid="inventory-status-counts">
|
||||
{abnormal > 0 && <span className="inventory-status-abnormal text-amber-700">其中异动 <strong>{abnormal}</strong> 台</span>}
|
||||
{pending > 0 && <span className="inventory-status-pending text-blue-700">其中待交车 <strong>{pending}</strong> 台</span>}
|
||||
{abnormal > 0 && <button type="button" className="inventory-status-abnormal text-amber-700 hover:underline focus-visible:outline-2 focus-visible:outline-amber-500 rounded-sm" title="分析当前范围内的异动车辆" onClick={event => {
|
||||
event.stopPropagation();
|
||||
onSelect(inventorySubtotalSelection(rows, rows.length === 1 ? rows[0].model : '异动', 'Abnormal'));
|
||||
}}>异动 <strong>{abnormal}</strong> 台</button>}
|
||||
{pending > 0 && <span className="inventory-status-pending text-blue-700">待交车 <strong>{pending}</strong> 台</span>}
|
||||
</span>;
|
||||
}
|
||||
|
||||
@@ -52,11 +52,11 @@ export function SummaryMetrics({
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]">
|
||||
<button type="button" data-testid="asset-abnormal-card" className="inline-flex items-center gap-1 text-amber-600 hover:underline"
|
||||
onClick={(event) => { event.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Abnormal', source: 'asset', title: '库存中的异动车辆' }); }}>
|
||||
<TriangleAlert size={12} />其中异动 <strong>{SUMMARY.inventory.abnormal}</strong>
|
||||
<TriangleAlert size={12} />异动 <strong>{SUMMARY.inventory.abnormal}</strong>
|
||||
</button>
|
||||
<button type="button" className="inline-flex items-center gap-1 text-blue-600 hover:underline"
|
||||
onClick={(event) => { event.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '库存中的待交车' }); }}>
|
||||
<PlusCircle size={12} />其中待交车 <strong>{SUMMARY.pendingDelivery}</strong>
|
||||
<PlusCircle size={12} />待交车 <strong>{SUMMARY.pendingDelivery}</strong>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,19 @@ import { fetchVehicleList } from '../../api';
|
||||
|
||||
const filters = { region: '华东', city: '上海', brand: '品牌甲', type: '18T', model: '批次一' };
|
||||
|
||||
test('abnormal inventory drill preserves exact row keys and intersects the abnormal category and company', () => {
|
||||
const rows = [{ ...filters, batch: '', quantity: 10, abnormal: 1 }];
|
||||
const selection = inventorySubtotalSelection(rows, rows[0].model, 'Abnormal');
|
||||
const request = buildVehicleModalRequest(selection, '公司甲');
|
||||
assert.equal(request.kind, 'vehicles');
|
||||
if (request.kind !== 'vehicles') throw new Error('wrong endpoint');
|
||||
assert.deepEqual(request.params.inventoryKeys, rows.map(inventoryKey));
|
||||
assert.equal(request.params.category, 'Abnormal');
|
||||
assert.equal(request.params.subject, '公司甲');
|
||||
assert.match(selection.scopeLabel!, /异动 1 台$/);
|
||||
assert.ok(selection.scopeLabel!.includes('批次一'));
|
||||
});
|
||||
|
||||
test('mobile region navigation shows only cities then types, preserving totals and exact leaf scope', () => {
|
||||
const rows = [
|
||||
{ ...filters, batch: '', quantity: 9 },
|
||||
@@ -115,10 +128,10 @@ test('mobile pages cover every record once and clamp after a filter reduces the
|
||||
|
||||
test('inventory status hints show nonzero subset counts and hide zeros', () => {
|
||||
const row = { ...filters, batch: '', quantity: 9, pending: 2, abnormal: 1 };
|
||||
const html = renderToStaticMarkup(createElement(InventoryStatusCounts, { rows: [row] }));
|
||||
assert.match(html, /其中待交车 <strong>2<\/strong> 台/);
|
||||
assert.match(html, /其中异动 <strong>1<\/strong> 台/);
|
||||
assert.equal(renderToStaticMarkup(createElement(InventoryStatusCounts, { rows: [{ ...row, pending: 0, abnormal: 0 }] })), '');
|
||||
const html = renderToStaticMarkup(createElement(InventoryStatusCounts, { onSelect: () => {}, rows: [row] }));
|
||||
assert.match(html, /待交车 <strong>2<\/strong> 台/);
|
||||
assert.match(html, /异动 <strong>1<\/strong> 台/);
|
||||
assert.equal(renderToStaticMarkup(createElement(InventoryStatusCounts, { onSelect: () => {}, rows: [{ ...row, pending: 0, abnormal: 0 }] })), '');
|
||||
});
|
||||
|
||||
test('filtered inventory totals retain all filters in the vehicle request', () => {
|
||||
|
||||
@@ -53,13 +53,15 @@ export function inventorySelection(filters: InventoryFilters, title: string, ove
|
||||
};
|
||||
}
|
||||
|
||||
export function inventorySubtotalSelection(rows: RegionalInventoryStats[], label: string): VehicleModalSelection {
|
||||
export function inventorySubtotalSelection(rows: RegionalInventoryStats[], label: string, category: 'Inventory' | 'Abnormal' = 'Inventory'): VehicleModalSelection {
|
||||
const common = (key: 'region' | 'city' | 'brand' | 'type') => rows.length && rows.every(row => row[key] === rows[0][key]) ? rows[0][key] : '';
|
||||
const scope = [...new Set([common('region'), common('city'), common('brand'), common('type'), label].filter(Boolean))].join(' · ');
|
||||
return {
|
||||
batch: 'All', model: 'All', location: 'All', source: 'asset', category: 'Inventory',
|
||||
batch: 'All', model: 'All', location: 'All', source: 'asset', category,
|
||||
inventoryKeys: [...new Set(rows.map(inventoryKey))],
|
||||
title: `库存统计 · ${scope}`, scopeLabel: `${scope} · 合计 ${inventoryTotal(rows)} 台`,
|
||||
title: `库存统计 · ${scope}`, scopeLabel: category === 'Abnormal'
|
||||
? `${scope} · 异动 ${rows.reduce((sum, row) => sum + (row.abnormal ?? 0), 0)} 台`
|
||||
: `${scope} · 合计 ${inventoryTotal(rows)} 台`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user