feat(assets): migrate asset views and reconcile inventory drilldowns
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user