461 lines
15 KiB
TypeScript
461 lines
15 KiB
TypeScript
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>
|
||
);
|
||
}
|