feat(assets): migrate asset views and reconcile inventory drilldowns

This commit is contained in:
shishengliang
2026-09-23 14:16:22 +08:00
parent b0cba26fbb
commit 822aabf2ca
77 changed files with 14669 additions and 3856 deletions
@@ -0,0 +1,460 @@
import { Fragment, useMemo, useState } from "react";
import {
ChevronDown,
ChevronRight,
ChevronsDownUp,
ListFilter,
Search,
X,
ArrowUpRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { RegionalInventoryStats } from "../types";
import { Picker, unique, useMobile } from "./shared";
import { ExportButton } from "./export";
import { InventoryExplorer } from "./InventoryExplorer";
import {
groupInventory,
INVENTORY_TYPES,
inventoryColumn,
inventoryTotals,
type InventoryScope,
} from "./inventory-view";
type Inspect = (scope: InventoryScope) => void;
export function InventoryNumber({
rows,
label,
inspect,
}: {
rows: RegionalInventoryStats[];
label: string;
inspect: Inspect;
}) {
const total = inventoryTotals(rows);
if (!total.quantity) return <span className="matrix-zero">0</span>;
return (
<div className="matrix-number">
<button
className="matrix-value"
onClick={() => inspect({ rows, label })}
aria-label={`${label},库存 ${total.quantity} 台,查看构成`}
>
{total.quantity.toLocaleString()}
<ArrowUpRight aria-hidden="true" />
</button>
{(total.pending > 0 || total.abnormal > 0) && (
<div className="matrix-secondary">
{total.pending > 0 && (
<button
onClick={() =>
inspect({ rows, label, initialCategory: "Pending" })
}
>
待交车 {total.pending}
</button>
)}
{total.abnormal > 0 && (
<button
className="matrix-warning"
onClick={() =>
inspect({ rows, label, initialCategory: "Abnormal" })
}
>
异动 {total.abnormal}
</button>
)}
</div>
)}
</div>
);
}
export function InventorySection({
inventory,
inspect,
subject,
}: {
subject: string | null;
inventory: RegionalInventoryStats[];
inspect: Inspect;
}) {
const mobile = useMobile();
const [query, setQuery] = useState("");
const [filters, setFilters] = useState({ region: "", city: "", brand: "" });
const [filterOpen, setFilterOpen] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [expandedType, setExpandedType] = useState("");
const filtered = useMemo(
() =>
inventory.filter(
(row) =>
Object.entries(filters).every(
([key, value]) =>
!value || row[key as keyof typeof filters] === value,
) &&
(!query.trim() ||
[
row.region,
row.city,
row.brand,
row.type,
row.model,
inventoryColumn(row.type),
]
.join(" ")
.toLowerCase()
.includes(query.trim().toLowerCase())),
),
[inventory, filters, query],
);
const rows = filtered;
const groups = groupInventory(rows, "region");
const brands = unique(
filtered
.filter((row) => inventoryColumn(row.type) === expandedType)
.map((row) => row.brand),
);
const hasBrandColumns = Boolean(expandedType) && brands.length > 0;
const columns = INVENTORY_TYPES.flatMap((type) => [
{
key: type,
title: expandedType === type && brands.length ? "小计" : type,
type,
brand: "",
},
...(expandedType === type
? brands.map((brand) => ({
key: `${type}-${brand}`,
title: brand,
type,
brand,
}))
: []),
]);
const activeCount = Object.values(filters).filter(Boolean).length;
const total = inventoryTotals(rows);
const toggleRegion = (region: string) =>
setExpanded((old) => {
const next = new Set(old);
if (next.has(region)) next.delete(region);
else next.add(region);
return next;
});
const cellRows = (
records: RegionalInventoryStats[],
type: string,
brand: string,
) =>
records.filter(
(row) =>
inventoryColumn(row.type) === type && (!brand || row.brand === brand),
);
const renderCells = (records: RegionalInventoryStats[], label: string) => (
<>
<td className="matrix-total">
<InventoryNumber rows={records} label={label} inspect={inspect} />
</td>
{columns.map((col) => (
<td
key={col.key}
className={[
col.brand ? "matrix-brand-cell" : "",
expandedType === col.type ? "matrix-selected-column" : "",
]
.filter(Boolean)
.join(" ")}
>
<InventoryNumber
rows={cellRows(records, col.type, col.brand)}
label={[label, col.type, col.brand].filter(Boolean).join(" / ")}
inspect={inspect}
/>
</td>
))}
</>
);
if (mobile)
return <InventoryExplorer inventory={inventory} subject={subject} />;
return (
<section className="inventory-matrix" aria-label="库存统计">
<header className="matrix-heading">
<div>
<h2>库存统计</h2>
<p>地区 × 车型总览,展开城市核对库存</p>
</div>
<div className="matrix-heading-actions">
<div className="matrix-header-total">
<span>{query || activeCount ? "筛选库存" : "总库存"}</span>
<InventoryNumber rows={rows} label="当前库存" inspect={inspect} />
</div>
<Button
variant="outline"
aria-expanded={filterOpen}
onClick={() => setFilterOpen(!filterOpen)}
>
<ListFilter aria-hidden="true" />
筛选{activeCount ? ` ${activeCount}` : ""}
</Button>
</div>
</header>
<div className="matrix-toolbar">
<label className="matrix-search">
<Search aria-hidden="true" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索区域、城市、品牌或型号"
aria-label="搜索库存"
/>
{query && (
<button onClick={() => setQuery("")} aria-label="清空库存搜索">
<X />
</button>
)}
</label>
<div className="matrix-tools">
<Button
variant="ghost"
onClick={() =>
setExpanded(
expanded.size
? new Set()
: new Set(groups.map((group) => group.name)),
)
}
>
<ChevronsDownUp aria-hidden="true" />
{expanded.size ? "收起区域" : "展开区域"}
</Button>
<ExportButton
rows={rows}
columns={[
{ key: "region", label: "区域" },
{ key: "city", label: "城市" },
{ key: "brand", label: "品牌" },
{
key: "type",
label: "车型",
format: (value) => inventoryColumn(String(value)),
},
{ key: "model", label: "原始型号" },
{ key: "quantity", label: "库存" },
{ key: "available", label: "可运营" },
{ key: "awaiting", label: "待运营" },
{ key: "abnormal", label: "异动" },
{ key: "pending", label: "待交车" },
]}
filename="库存统计"
sheetName="库存"
/>
</div>
</div>
{filterOpen && (
<div className="matrix-filters">
<Picker
label="区域"
value={filters.region}
values={unique(inventory.map((row) => row.region))}
change={(region) =>
setFilters((old) => ({ ...old, region, city: "" }))
}
/>
<Picker
label="城市"
value={filters.city}
values={unique(
inventory
.filter(
(row) => !filters.region || row.region === filters.region,
)
.map((row) => row.city),
)}
change={(city) => setFilters((old) => ({ ...old, city }))}
/>
<Picker
label="品牌"
value={filters.brand}
values={unique(inventory.map((row) => row.brand))}
change={(brand) => setFilters((old) => ({ ...old, brand }))}
/>
<Button
variant="ghost"
onClick={() => {
setFilters({ region: "", city: "", brand: "" });
setQuery("");
}}
>
重置筛选
</Button>
</div>
)}
{activeCount > 0 && (
<div className="matrix-filter-summary">
{Object.values(filters).filter(Boolean).join(" / ")}
<button
onClick={() => setFilters({ region: "", city: "", brand: "" })}
>
清除条件
</button>
</div>
)}
<div className="matrix-type-overview" aria-label="各车型库存总览">
{INVENTORY_TYPES.map((type) => {
const counts = inventoryTotals(cellRows(rows, type, ""));
return (
<button
key={type}
aria-pressed={expandedType === type}
onClick={() => setExpandedType(expandedType === type ? "" : type)}
>
<span>
{type}
<ChevronDown aria-hidden="true" />
</span>
<strong>
{counts.quantity.toLocaleString()}
<small>台</small>
</strong>
<span>可运营 {counts.available.toLocaleString()}</span>
</button>
);
})}
</div>
<div className="matrix-reading-guide">
<span>地区合计与车型合计均为当前筛选范围</span>
<span>点击车型展开品牌 · 点击数字查看明细</span>
</div>
{!rows.length ? (
<div className="matrix-empty">
<strong>暂无匹配库存</strong>
<p>尝试切换车型或调整筛选条件</p>
<Button
variant="outline"
onClick={() => {
setQuery("");
setFilters({ region: "", city: "", brand: "" });
}}
>
查看全部库存
</Button>
</div>
) : (
<div
className="matrix-scroll"
tabIndex={0}
aria-label="库存矩阵,可横向滚动"
>
<table className="matrix-table">
<caption className="sr-only">
区域展开城市,车型展开品牌,点击库存数量查看构成与车辆明细
</caption>
<thead>
<tr>
<th
rowSpan={hasBrandColumns ? 2 : 1}
scope="col"
className="matrix-geography"
>
区域 / 城市
</th>
<th
rowSpan={hasBrandColumns ? 2 : 1}
scope="col"
className="matrix-total"
>
总库存
</th>
{INVENTORY_TYPES.map((type) => (
<th
key={type}
colSpan={expandedType === type ? 1 + brands.length : 1}
rowSpan={expandedType === type || !hasBrandColumns ? 1 : 2}
scope="colgroup"
>
<button
className="matrix-column-toggle"
aria-expanded={expandedType === type}
onClick={() =>
setExpandedType(expandedType === type ? "" : type)
}
>
{type}
{expandedType === type ? (
<ChevronDown />
) : (
<ChevronRight />
)}
</button>
</th>
))}
</tr>
{hasBrandColumns && (
<tr>
<th scope="col">小计</th>
{brands.map((brand) => (
<th key={brand} scope="col">
{brand}
</th>
))}
</tr>
)}
</thead>
<tbody>
{groups.map((group) => (
<Fragment key={group.name}>
<tr className="matrix-region-row">
<th scope="row" className="matrix-geography">
<button
className="matrix-expand"
aria-expanded={expanded.has(group.name)}
onClick={() => toggleRegion(group.name)}
>
{expanded.has(group.name) ? (
<ChevronDown />
) : (
<ChevronRight />
)}
{group.name}
<small>{group.cities.length} 城市</small>
</button>
</th>
{renderCells(
group.cities.flatMap((city) => city.rows),
group.name,
)}
</tr>
{expanded.has(group.name) &&
group.cities.map((city) => (
<tr key={city.key} className="matrix-city-row">
<th scope="row" className="matrix-geography">
<span>{city.name}</span>
</th>
{renderCells(city.rows, `${group.name} / ${city.name}`)}
</tr>
))}
</Fragment>
))}
</tbody>
<tfoot>
<tr>
<th scope="row" className="matrix-geography">
当前范围合计
</th>
{renderCells(rows, "当前库存合计")}
</tr>
</tfoot>
</table>
</div>
)}
<footer className="matrix-footer">
<span>
{groups.length} 个区域 ·{" "}
{groups.reduce((sum, group) => sum + group.cities.length, 0)} 个城市 ·{" "}
{total.quantity} 台
</span>
<span>异动、待交车已包含在库存中 ·「其他」包含挂车及未单列车型</span>
</footer>
</section>
);
}