Files
ln-bi/src/modules/assets/library/InventorySection.tsx
T

461 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}