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