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

476 lines
16 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 { useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowRight,
ChevronRight,
MapPin,
Search,
Truck,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { RegionalInventoryStats } from "../types";
import { Picker, unique } from "./shared";
import { VehicleDetails } from "./VehicleDetails";
import {
INVENTORY_TYPES,
inventoryModel,
inventoryTotals,
scopedInventorySelection,
} from "./inventory-view";
import {
inventoryBrowseRows,
inventoryBrowseUrl,
inventoryModels,
inventoryPlaces,
inventoryTypeRows,
readInventoryBrowse,
type InventoryBrowseState,
} from "./inventory-explorer";
const fmt = (value: number) => value.toLocaleString();
const regionName = (value: string | null) => value || "未归属地区";
const cityName = (value: string | null) => value || "未归属城市";
export function InventoryExplorer({
inventory,
subject,
}: {
inventory: RegionalInventoryStats[];
subject: string | null;
}) {
const [state, setState] = useState(() =>
readInventoryBrowse(window.location.search),
);
const sectionRef = useRef<HTMLElement>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const positions = useRef(
new Map<
string,
{ scroll: [Element, number][]; y: number; focus?: string }
>(),
);
const currentUrl = useRef(window.location.href);
const locationKey = JSON.stringify([state.region, state.city]);
const lastLocation = useRef(locationKey);
const remember = () => {
const scroll: [Element, number][] = [];
let element = sectionRef.current?.parentElement;
while (element) {
if (element.scrollHeight > element.clientHeight)
scroll.push([element, element.scrollTop]);
element = element.parentElement;
}
positions.current.set(currentUrl.current, {
scroll,
y: window.scrollY,
focus: (document.activeElement as HTMLElement | null)?.dataset
.inventoryKey,
});
};
useEffect(() => {
const onBack = () => {
currentUrl.current = window.location.href;
setState(readInventoryBrowse(window.location.search));
};
window.addEventListener("popstate", onBack);
return () => window.removeEventListener("popstate", onBack);
}, []);
useEffect(() => {
if (lastLocation.current === locationKey) return;
lastLocation.current = locationKey;
const saved = positions.current.get(currentUrl.current);
if (saved) {
const target = saved.focus
? Array.from(
sectionRef.current?.querySelectorAll<HTMLElement>(
"[data-inventory-key]",
) || [],
).find((element) => element.dataset.inventoryKey === saved.focus)
: null;
(target || headingRef.current)?.focus({ preventScroll: true });
saved.scroll.forEach(([element, top]) => {
element.scrollTop = top;
});
window.scrollTo({ top: saved.y, behavior: "instant" });
} else {
headingRef.current?.focus({ preventScroll: true });
headingRef.current?.scrollIntoView({
block: "nearest",
behavior: "instant",
});
}
}, [locationKey]);
const navigate = (change: Partial<InventoryBrowseState>, replace = false) => {
remember();
const next = { ...state, ...change };
const url = inventoryBrowseUrl(window.location.href, next);
if (url === window.location.href) return;
if (replace) window.history.replaceState(window.history.state, "", url);
else
window.history.pushState(
{ ...window.history.state, inventoryParent: window.location.href },
"",
url,
);
currentUrl.current = url;
setState(next);
};
const goBack = () => {
const next =
state.model !== null
? { model: null }
: state.city !== null
? { city: null, model: null }
: { region: null, city: null, model: null };
const url = inventoryBrowseUrl(window.location.href, { ...state, ...next });
if (window.history.state?.inventoryParent === url) window.history.back();
else navigate(next);
};
const scoped = useMemo(
() => inventoryBrowseRows(inventory, state),
[inventory, state],
);
const rows = useMemo(
() => inventoryTypeRows(scoped, state.type),
[scoped, state.type],
);
const total = inventoryTotals(rows);
const places = inventoryPlaces(
scoped,
state.region === null ? "region" : "city",
state.type,
);
const models = useMemo(() => inventoryModels(rows), [rows]);
const chosenModel = models.find((model) => model.key === state.model);
const label = [
state.region === null ? "全部地区" : regionName(state.region),
state.city === null ? "" : cityName(state.city),
state.type || "全部车型",
]
.filter(Boolean)
.join(" / ");
const selection = useMemo(
() =>
chosenModel
? scopedInventorySelection({
rows: chosenModel.rows,
label: `${label} / ${chosenModel.brand || "未标注品牌"} / ${chosenModel.model}`,
})
: null,
[chosenModel, label],
);
const level = state.region === null ? 0 : state.city === null ? 1 : 2;
const hasFilters = Boolean(state.query || state.brand);
return (
<section
ref={sectionRef}
className="inventory-matrix inventory-explorer"
aria-label="库存找车"
>
<header className="finder-header">
<div>
<h2>库存找车</h2>
<p>选地区、选城市,快速找到对应车辆</p>
</div>
<MapPin aria-hidden="true" />
</header>
<ol className="finder-steps" aria-label="找车步骤">
{["选地区", "选城市", "查车辆"].map((title, index) => (
<li key={title} aria-current={index === level ? "step" : undefined}>
<span>{index + 1}</span>
{title}
{index < 2 && <ChevronRight aria-hidden="true" />}
</li>
))}
</ol>
<nav className="finder-breadcrumb" aria-label="库存位置">
{state.region === null ? (
<span aria-current="page">全部地区</span>
) : (
<button
onClick={() => navigate({ region: null, city: null, model: null })}
>
全部地区
</button>
)}
{state.region !== null && (
<>
<ChevronRight aria-hidden="true" />
{state.city === null ? (
<span aria-current="page">{regionName(state.region)}</span>
) : (
<button onClick={() => navigate({ city: null, model: null })}>
{regionName(state.region)}
</button>
)}
</>
)}
{state.city !== null && (
<>
<ChevronRight aria-hidden="true" />
<span aria-current="page">{cityName(state.city)}</span>
</>
)}
</nav>
<div className="finder-scope">
{level > 0 && (
<button
className="finder-back"
aria-label={level === 2 ? "返回城市列表" : "返回地区列表"}
onClick={goBack}
>
<ArrowLeft aria-hidden="true" />
</button>
)}
<div>
<h3 ref={headingRef} tabIndex={-1}>
{state.city !== null
? cityName(state.city)
: state.region !== null
? regionName(state.region)
: "地区库存"}
</h3>
<p>
{state.type || "全部车型"}
{hasFilters ? " · 已筛选" : ""} ·{" "}
{state.city !== null
? `${models.length} 种型号`
: `${places.filter((place) => place.total.quantity > 0).length} 个${level === 0 ? "地区" : "城市"}有库存`}
</p>
</div>
<div className="finder-scope-total" aria-live="polite">
<strong>{fmt(total.quantity)}</strong>
<span>库存 / 台</span>
</div>
</div>
<div className="finder-type-heading">
<strong>找什么车型</strong>
<span>数量为当前范围库存</span>
</div>
<div className="matrix-mobile-types finder-types" aria-label="库存车型">
{["", ...INVENTORY_TYPES].map((type) => (
<button
key={type}
aria-pressed={state.type === type}
onClick={() => navigate({ type, model: null })}
>
{type || "全部车型"}
<span>
{fmt(inventoryTotals(inventoryTypeRows(scoped, type)).quantity)}
</span>
</button>
))}
</div>
<div className="finder-search-row">
<label className="matrix-search">
<Search aria-hidden="true" />
<Input
value={state.query}
placeholder={
level === 0
? "搜索地区、城市、品牌或型号"
: "搜索当前范围的城市、品牌或型号"
}
aria-label="搜索当前范围库存"
onChange={(event) =>
navigate({ query: event.target.value, model: null }, true)
}
/>
{state.query && (
<button
aria-label="清空库存搜索"
onClick={() => navigate({ query: "", model: null }, true)}
>
<X aria-hidden="true" />
</button>
)}
</label>
<Picker
label="品牌"
value={state.brand}
values={unique(inventory.map((row) => row.brand))}
change={(brand) => navigate({ brand, model: null })}
/>
</div>
<div className="finder-results-heading">
<strong>
{level === 0
? "选择地区"
: level === 1
? "选择城市"
: "选择型号查看车辆"}
</strong>
{hasFilters ? (
<button
onClick={() => navigate({ query: "", brand: "", model: null })}
>
清除筛选
</button>
) : (
<span>{level < 2 ? "按库存从多到少" : "点击进入车辆明细"}</span>
)}
</div>
{total.quantity === 0 && (
<div className="finder-empty" role="status">
<Truck aria-hidden="true" />
<strong>
{hasFilters
? "当前范围没有匹配车辆"
: state.type
? `当前范围暂无${state.type}库存`
: "当前范围暂无库存"}
</strong>
<p>
{hasFilters
? "试试清除搜索或品牌条件,也可以返回上级查找。"
: "可切换车型,或返回上级查看其他地区、城市。"}
</p>
{(state.type || hasFilters) && (
<Button
variant="outline"
onClick={() =>
navigate({ type: "", query: "", brand: "", model: null })
}
>
查看当前范围全部库存
</Button>
)}
{level > 0 && (
<Button variant="ghost" onClick={goBack}>
返回{level === 2 ? "城市" : "地区"}列表
</Button>
)}
</div>
)}
{level < 2 ? (
<div className="finder-list">
{places.map((place) => (
<button
key={place.key}
className="finder-place"
data-inventory-key={place.key}
disabled={!place.total.quantity}
aria-label={`${place.name},${state.type || "全部车型"}库存 ${place.total.quantity} 台,${level === 0 ? "选择城市" : "查看车型"}`}
onClick={() =>
navigate(
level === 0
? { region: place.value, city: null, model: null }
: { city: place.value, model: null },
)
}
>
<span className="finder-place-top">
<strong>{place.name}</strong>
<span className="finder-place-count">
{fmt(place.total.quantity)}
<small>台</small>
{place.total.quantity > 0 && (
<ChevronRight aria-hidden="true" />
)}
</span>
</span>
{place.total.quantity > 0 ? (
<>
<span className="finder-place-status">
{level === 0 && <span>{place.cities} 个城市有库存</span>}
<span>可运营 {fmt(place.total.available)}</span>
<span>待运营 {fmt(place.total.awaiting)}</span>
</span>
<span className="finder-type-tags">
{(state.type
? place.types.filter((item) => item.name === state.type)
: place.types
).map((item) => (
<span key={item.name}>
{item.name}
<b>{fmt(item.quantity)}</b>
</span>
))}
</span>
</>
) : (
<span className="finder-place-status">
{state.type ? "该车型暂无库存" : "暂无库存"}
</span>
)}
</button>
))}
</div>
) : (
<div className="finder-list">
{models.map((model) => (
<button
key={model.key}
className="finder-place finder-model"
data-inventory-key={model.key}
onClick={() => navigate({ model: model.key })}
>
<span className="finder-place-top">
<strong>{inventoryModel(model.model, model.brand).name}</strong>
<span className="finder-place-count">
{fmt(model.total.quantity)}
<small>台</small>
</span>
</span>
<span className="finder-model-meta">
{model.brand || "未标注品牌"} · {model.type}
{inventoryModel(model.model, model.brand).operator &&
` · ${inventoryModel(model.model, model.brand).operator}`}
</span>
<span className="finder-place-status">
<span>可运营 {fmt(model.total.available)}</span>
<span>待运营 {fmt(model.total.awaiting)}</span>
{model.total.unclassified > 0 && (
<span>待核实 {fmt(model.total.unclassified)}</span>
)}
</span>
<span className="finder-model-footer">
<span>
{[
model.total.pending
? `待交车 ${fmt(model.total.pending)}`
: "",
model.total.abnormal
? `异动 ${fmt(model.total.abnormal)}`
: "",
]
.filter(Boolean)
.join(" · ")}
</span>
<span>
查看车辆
<ArrowRight aria-hidden="true" />
</span>
</span>
</button>
))}
</div>
)}
{state.model !== null && !chosenModel && (
<div className="finder-empty" role="status">
<strong>该型号已不在当前库存范围</strong>
<Button variant="outline" onClick={goBack}>
返回选择型号
</Button>
</div>
)}
<footer className="matrix-footer">
<span>库存包含待交车、异动车辆,不代表均可立即交付。</span>
<span>「其他」包含挂车及未单列车型。</span>
</footer>
<VehicleDetails
selection={selection}
subject={subject}
close={goBack}
returnLabel="返回找车结果"
/>
</section>
);
}