feat(assets): migrate asset views and reconcile inventory drilldowns
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
import { useState } from "react";
|
||||
import { InventoryAnalysis } from "./InventoryAnalysis";
|
||||
import { inventoryTotals, type InventoryScope } from "./inventory-view";
|
||||
import { InventorySection } from "./InventorySection";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardAction,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
Package,
|
||||
Truck,
|
||||
ArrowLeftRight,
|
||||
AlertTriangle,
|
||||
Activity,
|
||||
Warehouse,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
SummaryData,
|
||||
TypeSummary,
|
||||
RegionalInventoryStats,
|
||||
ModelSummary,
|
||||
BatchSummary,
|
||||
} from "../types";
|
||||
import type { VehicleModalCategory, VehicleModalSelection } from "../model";
|
||||
import type { AssetSection } from "./useAssetHash";
|
||||
import {
|
||||
baseSelection,
|
||||
ProportionBar,
|
||||
sectionTabsListClass,
|
||||
sectionTabsTriggerClass,
|
||||
type OpenVehicles,
|
||||
} from "./shared";
|
||||
import { DataGrid, type Column } from "./DataGrid";
|
||||
import { ExportButton } from "./export";
|
||||
import { FlowStatistics } from "./FlowStatistics";
|
||||
|
||||
/** 运营率环形图(SVG donut)。 */
|
||||
function RateDonut({ rate, size = 48 }: { rate: number; size?: number }) {
|
||||
const stroke = 6;
|
||||
const r = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const filled = (Math.min(100, Math.max(0, rate)) / 100) * circumference;
|
||||
return (
|
||||
<div
|
||||
className="relative shrink-0"
|
||||
style={{ width: size, height: size }}
|
||||
role="img"
|
||||
aria-label={`运营率 ${rate.toFixed(1)}%`}
|
||||
>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="-rotate-90"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="var(--fill-3)"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="var(--blue)"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${filled} ${circumference}`}
|
||||
className="transition-[stroke-dasharray] duration-700 ease-out"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className="absolute inset-0 grid place-items-center text-[11px] font-bold text-[var(--label)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{rate.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function AssetOverview({
|
||||
summary: s,
|
||||
types,
|
||||
inventory,
|
||||
subject,
|
||||
section,
|
||||
onSectionChange,
|
||||
open,
|
||||
}: {
|
||||
summary: SummaryData;
|
||||
types: TypeSummary[];
|
||||
inventory: RegionalInventoryStats[];
|
||||
subject: string | null;
|
||||
section: AssetSection;
|
||||
onSectionChange: (section: AssetSection) => void;
|
||||
open: OpenVehicles;
|
||||
}) {
|
||||
const [inventoryScope, setInventoryScope] = useState<InventoryScope | null>(
|
||||
null,
|
||||
);
|
||||
const inventoryCounts = inventoryTotals(inventory);
|
||||
const operatingRate = s.totalAssets
|
||||
? (s.operating.total / s.totalAssets) * 100
|
||||
: 0;
|
||||
const show = (category?: VehicleModalCategory) => {
|
||||
if (
|
||||
category === "Inventory" ||
|
||||
category === "Abnormal" ||
|
||||
category === "Pending"
|
||||
)
|
||||
setInventoryScope({
|
||||
rows: inventory,
|
||||
label: "全部库存",
|
||||
initialCategory: category === "Inventory" ? undefined : category,
|
||||
});
|
||||
else open({ ...baseSelection, category });
|
||||
};
|
||||
return (
|
||||
<div className="asset-overview grid min-w-0 gap-3">
|
||||
<div className="grid gap-2 md:grid-cols-2" aria-label="资产构成">
|
||||
<Card size="sm" className="asset-kpi ring-0 shadow-[var(--shadow-1)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 font-semibold group-data-[size=sm]/card:text-base text-[var(--label)] dark:text-[var(--cyan)]">
|
||||
<Activity aria-hidden="true" />
|
||||
总运营
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<RateDonut rate={operatingRate} />
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-[var(--label-secondary)]">
|
||||
运营率
|
||||
</p>
|
||||
<p className="text-sm font-semibold tabular-nums whitespace-nowrap text-[var(--label)]">
|
||||
{operatingRate.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardAction>
|
||||
<CardDescription>租赁 + 自营</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-end justify-between gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="asset-kpi-number"
|
||||
onClick={() => show("Operating")}
|
||||
>
|
||||
{s.operating.total.toLocaleString()}{" "}
|
||||
<span className="asset-kpi-unit">台</span>
|
||||
<ArrowUpRight aria-hidden="true" />
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Badge className="bg-[var(--bg-tertiary)] text-[var(--label)]">
|
||||
租赁 {s.operating.leased}
|
||||
</Badge>
|
||||
<Badge className="bg-[var(--bg-tertiary)] text-[var(--label)]">
|
||||
自营 {s.operating.self}
|
||||
</Badge>
|
||||
</div>
|
||||
<ProportionBar
|
||||
label={`租赁 ${s.operating.leased} 台,自营 ${s.operating.self} 台`}
|
||||
segments={[
|
||||
{
|
||||
value: s.operating.leased,
|
||||
className: "bg-[var(--blue)]",
|
||||
title: "租赁",
|
||||
},
|
||||
{
|
||||
value: s.operating.self,
|
||||
className: "bg-[var(--teal)]",
|
||||
title: "自营",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card
|
||||
size="sm"
|
||||
className="asset-kpi inventory ring-0 shadow-[var(--shadow-1)]"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 font-semibold group-data-[size=sm]/card:text-base text-[var(--label)] dark:text-[var(--cyan)]">
|
||||
<Warehouse aria-hidden="true" />
|
||||
总库存
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
待运营 + 可运营;异动、待交车已包含在内
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-end justify-between gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="asset-kpi-number"
|
||||
onClick={() => show("Inventory")}
|
||||
>
|
||||
{s.inventory.total.toLocaleString()}{" "}
|
||||
<span className="asset-kpi-unit">台</span>
|
||||
<ArrowUpRight aria-hidden="true" />
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-orange-200 bg-orange-50 text-orange-800 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-200"
|
||||
disabled={!s.inventory.abnormal}
|
||||
onClick={() => show("Abnormal")}
|
||||
>
|
||||
<AlertTriangle
|
||||
data-icon="inline-start"
|
||||
className="text-[var(--orange)]"
|
||||
/>
|
||||
异动 {s.inventory.abnormal}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-200"
|
||||
disabled={!s.pendingDelivery}
|
||||
onClick={() => show("Pending")}
|
||||
>
|
||||
待交车 {s.pendingDelivery}
|
||||
</Button>
|
||||
</div>
|
||||
<ProportionBar
|
||||
label={`可运营 ${inventoryCounts.available} 台,待运营 ${inventoryCounts.awaiting} 台`}
|
||||
segments={[
|
||||
{
|
||||
value: inventoryCounts.available,
|
||||
className: "bg-[var(--blue)]",
|
||||
title: "可运营",
|
||||
},
|
||||
{
|
||||
value: inventoryCounts.awaiting,
|
||||
className: "bg-[var(--inventory-awaiting)]",
|
||||
title: "待运营",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Tabs
|
||||
value={section}
|
||||
onValueChange={(value) => onSectionChange(value as AssetSection)}
|
||||
>
|
||||
<TabsList className={sectionTabsListClass}>
|
||||
<TabsTrigger className={sectionTabsTriggerClass} value="inventory">
|
||||
<Package />
|
||||
库存统计
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className={sectionTabsTriggerClass} value="models">
|
||||
<Truck />
|
||||
车型资产
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className={sectionTabsTriggerClass} value="flow">
|
||||
<ArrowLeftRight />
|
||||
交还车统计
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="inventory">
|
||||
<InventorySection
|
||||
inventory={inventory}
|
||||
subject={subject}
|
||||
inspect={setInventoryScope}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="models">
|
||||
<Card
|
||||
size="sm"
|
||||
className="bg-[var(--bg-tertiary)] ring-0 shadow-[var(--shadow-2)]"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-semibold group-data-[size=sm]/card:text-base text-[var(--label)] dark:text-[var(--cyan)]">
|
||||
车型资产
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
按车型、型号与批次逐层查看;本周交还车按车辆去重。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion type="multiple">
|
||||
{types.map((type) => (
|
||||
<AccordionItem key={type.type} value={type.type}>
|
||||
<AccordionTrigger>
|
||||
{type.type} · {type.totalAssets} 台 · 库存{" "}
|
||||
{type.totalInventory}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ModelTable
|
||||
rows={type.models}
|
||||
open={open}
|
||||
selection={{ ...baseSelection }}
|
||||
/>
|
||||
<Accordion type="multiple">
|
||||
{type.models.map((model) => (
|
||||
<AccordionItem key={model.model} value={model.model}>
|
||||
<AccordionTrigger>
|
||||
{model.model} · 查看批次
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ModelTable
|
||||
rows={model.batches}
|
||||
open={open}
|
||||
selection={{
|
||||
...baseSelection,
|
||||
model: model.model,
|
||||
}}
|
||||
batch
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="flow">
|
||||
<FlowStatistics subject={subject} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{inventoryScope && (
|
||||
<InventoryAnalysis
|
||||
scope={inventoryScope}
|
||||
subject={subject}
|
||||
close={() => setInventoryScope(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function ModelTable({
|
||||
rows,
|
||||
open,
|
||||
selection,
|
||||
batch = false,
|
||||
}: {
|
||||
rows: (ModelSummary | BatchSummary)[];
|
||||
open: OpenVehicles;
|
||||
selection: VehicleModalSelection;
|
||||
batch?: boolean;
|
||||
}) {
|
||||
const data = rows.map((row) => ({
|
||||
...row,
|
||||
name: "model" in row ? row.model : row.batch,
|
||||
}));
|
||||
type Row = (typeof data)[number];
|
||||
const columns: Column<Row>[] = [
|
||||
{ key: "name", label: batch ? "批次" : "型号", width: 190 },
|
||||
...(
|
||||
[
|
||||
["total", "资产", undefined],
|
||||
["operating", "运营", "Operating"],
|
||||
["inventory", "库存", "Inventory"],
|
||||
["abnormal", "异动", "Abnormal"],
|
||||
["pending", "待交车", "Pending"],
|
||||
["weeklyDelivered", "本周交车", "Delivered"],
|
||||
["weeklyReturned", "本周还车", "Returned"],
|
||||
["weeklyReplaced", "替换交车", "Replaced"],
|
||||
] as const
|
||||
).map(([key, label, category]) => ({
|
||||
key,
|
||||
label,
|
||||
action: (row: Row) =>
|
||||
open({
|
||||
...selection,
|
||||
...(batch ? { batch: row.name } : { model: row.name }),
|
||||
category,
|
||||
}),
|
||||
})),
|
||||
{
|
||||
key: "inventoryRegions",
|
||||
label: "库存分布",
|
||||
width: 260,
|
||||
format: (value) => formatInventoryRegions(value),
|
||||
tooltip: (value) => formatInventoryRegions(value),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<ExportButton
|
||||
rows={data}
|
||||
columns={columns}
|
||||
filename={batch ? "批次资产" : "型号资产"}
|
||||
sheetName={batch ? "批次资产" : "型号资产"}
|
||||
/>
|
||||
<DataGrid
|
||||
rows={data}
|
||||
columns={columns}
|
||||
label={batch ? "批次资产" : "型号资产"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 库存分布:只展示有库存的区域,避免 0 值噪声;截断时由 tooltip 展示完整内容。 */
|
||||
function formatInventoryRegions(value: unknown): string {
|
||||
const parts = Object.entries(value as Record<string, number>)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([region, count]) => `${region} ${count}`);
|
||||
return parts.join(" · ") || "—";
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type {
|
||||
DeptGroup,
|
||||
ManagerStats,
|
||||
RegionGroup,
|
||||
RegionTypeBreakdown,
|
||||
CustomerStats,
|
||||
} from "../types";
|
||||
import {
|
||||
deriveDepartmentView,
|
||||
deriveCustomerView,
|
||||
type CustomerFilters,
|
||||
} from "../model";
|
||||
import { fetchRegionStats, fetchRegionChart } from "../api";
|
||||
const APPLE_CHART_COLORS = [
|
||||
"var(--indigo)",
|
||||
"var(--blue)",
|
||||
"var(--teal)",
|
||||
"var(--orange)",
|
||||
"var(--green)",
|
||||
];
|
||||
import {
|
||||
baseSelection,
|
||||
FilterFields,
|
||||
PrivateText,
|
||||
unique,
|
||||
Failure,
|
||||
type OpenVehicles,
|
||||
} from "./shared";
|
||||
import { DataGrid, type Column } from "./DataGrid";
|
||||
import { ExportButton } from "./export";
|
||||
import { Chart } from "./Chart";
|
||||
const typeFields = [
|
||||
["t4_5", "4.5T普货"],
|
||||
["t4_5c", "4.5T冷链"],
|
||||
["t18", "18T"],
|
||||
["t49", "49T"],
|
||||
["trailer", "挂车"],
|
||||
["other", "其他"],
|
||||
] as const;
|
||||
export function DepartmentView({
|
||||
data,
|
||||
open,
|
||||
}: {
|
||||
data: DeptGroup[];
|
||||
open: OpenVehicles;
|
||||
}) {
|
||||
const [mode, setMode] = useState("department");
|
||||
const [manager, setManager] = useState("");
|
||||
const view = deriveDepartmentView(data, manager || "All");
|
||||
const cols: Column<DeptGroup>[] = [
|
||||
{ key: "department", label: "部门", width: 180 },
|
||||
{
|
||||
key: "totalAssets",
|
||||
label: "车辆总数",
|
||||
action: (r) =>
|
||||
open({
|
||||
...baseSelection,
|
||||
source: "department",
|
||||
category: "Operating",
|
||||
department: r.department,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "operatingCount",
|
||||
label: "出勤车辆",
|
||||
action: (r) =>
|
||||
open({
|
||||
...baseSelection,
|
||||
source: "department",
|
||||
category: "Operating",
|
||||
department: r.department,
|
||||
attendance: "active",
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "idleCount",
|
||||
label: "未出勤车辆",
|
||||
action: (r) =>
|
||||
open({
|
||||
...baseSelection,
|
||||
source: "department",
|
||||
category: "Operating",
|
||||
department: r.department,
|
||||
attendance: "idle",
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "attendanceRate",
|
||||
label: "出勤率",
|
||||
numeric: true,
|
||||
format: (v) => `${Number(v).toFixed(1)}%`,
|
||||
},
|
||||
{
|
||||
key: "avgMileage",
|
||||
label: "平均里程(km)",
|
||||
width: 160,
|
||||
numeric: true,
|
||||
format: (v) => Number(v).toFixed(1),
|
||||
},
|
||||
];
|
||||
const managerCols: Column<ManagerStats>[] = [
|
||||
{ key: "manager", label: "业务负责人", private: true },
|
||||
{ key: "department", label: "部门", width: 160 },
|
||||
...typeFields.map(([key, type]) => ({
|
||||
key,
|
||||
label: type,
|
||||
action: (r: ManagerStats) =>
|
||||
open({
|
||||
...baseSelection,
|
||||
source: "department",
|
||||
category: "Operating",
|
||||
department: r.department,
|
||||
manager: r.manager,
|
||||
type,
|
||||
}),
|
||||
})),
|
||||
{
|
||||
key: "total",
|
||||
label: "合计",
|
||||
action: (r) =>
|
||||
open({
|
||||
...baseSelection,
|
||||
source: "department",
|
||||
category: "Operating",
|
||||
department: r.department,
|
||||
manager: r.manager,
|
||||
}),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Card
|
||||
size="sm"
|
||||
className="bg-[var(--bg-tertiary)] ring-0 shadow-[var(--shadow-2)]"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-semibold group-data-[size=sm]/card:text-base text-[var(--label)] dark:text-[var(--cyan)]">
|
||||
部门运营
|
||||
</CardTitle>
|
||||
<CardDescription>查看部门出勤表现与负责人车辆分布。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Tabs value={mode} onValueChange={setMode}>
|
||||
<TabsList className="min-h-11">
|
||||
<TabsTrigger className="min-h-11 px-3" value="department">按部门</TabsTrigger>
|
||||
<TabsTrigger className="min-h-11 px-3" value="manager">按负责人</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{mode === "department" ? (
|
||||
<ExportButton
|
||||
rows={data}
|
||||
columns={cols}
|
||||
filename="部门运营-按部门"
|
||||
sheetName="部门"
|
||||
/>
|
||||
) : (
|
||||
<ExportButton
|
||||
rows={view.managerStats}
|
||||
columns={managerCols}
|
||||
filename="部门运营-按负责人"
|
||||
sheetName="负责人"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{mode === "department" ? (
|
||||
<>
|
||||
<DataGrid rows={data} columns={cols} label="部门" />
|
||||
<Accordion type="multiple">
|
||||
{data.map((d) => (
|
||||
<AccordionItem key={d.department} value={d.department}>
|
||||
<AccordionTrigger>
|
||||
{d.department} · 负责人明细
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<DataGrid
|
||||
rows={d.managers}
|
||||
columns={managerCols}
|
||||
label="负责人"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FilterFields
|
||||
fields={[
|
||||
{
|
||||
label: "负责人",
|
||||
value: manager,
|
||||
values: view.managers,
|
||||
change: setManager,
|
||||
},
|
||||
]}
|
||||
onReset={() => setManager("")}
|
||||
/>
|
||||
<DataGrid
|
||||
rows={view.managerStats}
|
||||
columns={managerCols}
|
||||
label="负责人"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
export function RegionView({
|
||||
data,
|
||||
subject,
|
||||
open,
|
||||
}: {
|
||||
data: RegionGroup[];
|
||||
subject: string | null;
|
||||
open: OpenVehicles;
|
||||
}) {
|
||||
const [chartView, setChartView] = useState("region");
|
||||
const [filters, setFilters] = useState({
|
||||
region: "",
|
||||
city: "",
|
||||
customer: "",
|
||||
});
|
||||
const [filtered, setFiltered] = useState<RegionGroup[]>(data);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [retry, setRetry] = useState(0);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!filters.region && !filters.city && !filters.customer) {
|
||||
setFiltered(data);
|
||||
setLoading(false);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
fetchRegionStats(filters, subject)
|
||||
.then((v) => {
|
||||
if (active) setFiltered(v);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setError(String(e.message || e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [filters, subject, data, retry]);
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
color: APPLE_CHART_COLORS,
|
||||
textStyle: { color: "var(--label-secondary)" },
|
||||
tooltip: { trigger: "axis" },
|
||||
grid: { left: 45, right: 15, top: 20, bottom: 45 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: filtered.map((r) => r.region),
|
||||
axisLabel: { color: "var(--label-secondary)", interval: 0 },
|
||||
axisLine: { lineStyle: { color: "var(--separator)" } },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
minInterval: 1,
|
||||
axisLabel: { color: "var(--label-secondary)" },
|
||||
splitLine: { lineStyle: { color: "var(--separator)" } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "bar",
|
||||
name: "车辆",
|
||||
data: filtered.map((r) => r.totalAssets),
|
||||
barMaxWidth: 48,
|
||||
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: "top",
|
||||
color: "var(--label-secondary)",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
[filtered],
|
||||
);
|
||||
const breakdown = (rows: RegionTypeBreakdown[], location: string) => (
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
label={`${location}车型`}
|
||||
columns={[
|
||||
{ key: "type", label: "车型" },
|
||||
...(
|
||||
[
|
||||
["total", "总数", undefined],
|
||||
["operating", "运营", "Operating"],
|
||||
["inventory", "库存", "Inventory"],
|
||||
["abnormal", "异动", "Abnormal"],
|
||||
["pending", "待交车", "Pending"],
|
||||
] as const
|
||||
).map(([key, label, category]) => ({
|
||||
key,
|
||||
label,
|
||||
action: (r: RegionTypeBreakdown) =>
|
||||
open({
|
||||
...baseSelection,
|
||||
source: "region",
|
||||
location: filters.city || location,
|
||||
customer: filters.customer || undefined,
|
||||
vehicleType: r.type === "其他" ? "区域其他" : r.type,
|
||||
category,
|
||||
}),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Card
|
||||
size="sm"
|
||||
className="bg-[var(--bg-tertiary)] ring-0 shadow-[var(--shadow-2)]"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-semibold group-data-[size=sm]/card:text-base text-[var(--label)] dark:text-[var(--cyan)]">
|
||||
区域分布
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
运营与待交车车辆,按区域 → 城市 → 车型查看。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<FilterFields
|
||||
fields={[
|
||||
{
|
||||
label: "区域",
|
||||
value: filters.region,
|
||||
values: data.map((r) => r.region),
|
||||
change: (v) => setFilters((f) => ({ ...f, region: v, city: "" })),
|
||||
},
|
||||
{
|
||||
label: "城市",
|
||||
value: filters.city,
|
||||
values: unique(
|
||||
data
|
||||
.filter((r) => !filters.region || r.region === filters.region)
|
||||
.flatMap((r) => r.cities.map((c) => c.city)),
|
||||
),
|
||||
change: (v) => setFilters((f) => ({ ...f, city: v })),
|
||||
},
|
||||
{
|
||||
label: "客户",
|
||||
value: filters.customer,
|
||||
values: unique(data.flatMap((r) => r.customers)),
|
||||
change: (v) => setFilters((f) => ({ ...f, customer: v })),
|
||||
},
|
||||
]}
|
||||
onReset={() => setFilters({ region: "", city: "", customer: "" })}
|
||||
/>
|
||||
{error ? (
|
||||
<Failure message={error} retry={() => setRetry((n) => n + 1)} />
|
||||
) : loading ? (
|
||||
<Skeleton className="h-64" />
|
||||
) : (
|
||||
<>
|
||||
<Tabs value={chartView} onValueChange={setChartView}>
|
||||
<TabsList className="min-h-11">
|
||||
<TabsTrigger className="min-h-11 px-3" value="region">区域分布</TabsTrigger>
|
||||
<TabsTrigger className="min-h-11 px-3" value="province">实时省份</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{chartView === "region" ? (
|
||||
<Chart option={option} label="各区域车辆数量" />
|
||||
) : (
|
||||
<ProvinceChart subject={subject} source="realtime" />
|
||||
)}
|
||||
<Accordion type="multiple">
|
||||
{filtered.map((r) => (
|
||||
<AccordionItem key={r.region} value={r.region}>
|
||||
<AccordionTrigger>
|
||||
{r.region} · {r.totalAssets} 台
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{breakdown(r.typeBreakdown, r.region)}
|
||||
<Accordion type="multiple">
|
||||
{r.cities.map((city) => (
|
||||
<AccordionItem key={city.city} value={city.city}>
|
||||
<AccordionTrigger>
|
||||
{city.city} · {city.totalAssets} 台
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{breakdown(city.typeBreakdown, city.city)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
{!filtered.length && (
|
||||
<CardDescription>暂无符合条件的区域数据</CardDescription>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const initialCustomer: CustomerFilters = {
|
||||
customer: [],
|
||||
brand: "",
|
||||
department: "",
|
||||
manager: "",
|
||||
region: "",
|
||||
};
|
||||
export function CustomerView({
|
||||
data,
|
||||
departments,
|
||||
subject,
|
||||
open,
|
||||
}: {
|
||||
data: CustomerStats[];
|
||||
subject: string | null;
|
||||
departments: DeptGroup[];
|
||||
open: OpenVehicles;
|
||||
}) {
|
||||
const [filters, setFilters] = useState(initialCustomer);
|
||||
const [customerSearch, setCustomerSearch] = useState("");
|
||||
const [chartGroup, setChartGroup] = useState("region");
|
||||
const view = deriveCustomerView(data, departments, filters);
|
||||
const option = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of view.filtered) {
|
||||
const name = chartGroup === "region" ? row.region : row.city;
|
||||
counts.set(
|
||||
name || "未归属",
|
||||
(counts.get(name || "未归属") || 0) + row.total,
|
||||
);
|
||||
}
|
||||
return {
|
||||
color: APPLE_CHART_COLORS,
|
||||
textStyle: { color: "var(--label-secondary)" },
|
||||
tooltip: { trigger: "item" },
|
||||
legend: {
|
||||
type: "scroll",
|
||||
bottom: 0,
|
||||
textStyle: { color: "var(--label-secondary)" },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: ["35%", "65%"],
|
||||
label: { show: false },
|
||||
data: [...counts].map(([name, value]) => ({ name, value })),
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [view.filtered, chartGroup]);
|
||||
const cols: Column<CustomerStats>[] = [
|
||||
{ key: "customer", label: "客户", width: 220, private: true },
|
||||
{ key: "manager", label: "负责人", private: true },
|
||||
{ key: "department", label: "部门", width: 150 },
|
||||
{ key: "brand", label: "品牌" },
|
||||
{ key: "region", label: "区域" },
|
||||
{ key: "city", label: "城市" },
|
||||
...typeFields.map(([key, type]) => ({
|
||||
key,
|
||||
label: type,
|
||||
action: (r: CustomerStats) =>
|
||||
open({
|
||||
...baseSelection,
|
||||
source: "customer",
|
||||
category: "Operating",
|
||||
customer: r.customer,
|
||||
type,
|
||||
}),
|
||||
})),
|
||||
{
|
||||
key: "total",
|
||||
label: "合计",
|
||||
action: (r) =>
|
||||
open({
|
||||
...baseSelection,
|
||||
source: "customer",
|
||||
category: "Operating",
|
||||
customer: r.customer,
|
||||
}),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Card
|
||||
size="sm"
|
||||
className="bg-[var(--bg-tertiary)] ring-0 shadow-[var(--shadow-2)]"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-semibold group-data-[size=sm]/card:text-base text-[var(--label)] dark:text-[var(--cyan)]">
|
||||
客户资产
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
按客户、负责人、品牌与区域查看车辆分布。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline">
|
||||
{filters.customer.length
|
||||
? `已选 ${filters.customer.length} 个客户`
|
||||
: "选择客户(可多选)"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<Input
|
||||
placeholder="搜索客户"
|
||||
aria-label="搜索客户选项"
|
||||
value={customerSearch}
|
||||
onChange={(e) => setCustomerSearch(e.target.value)}
|
||||
/>
|
||||
<div className="grid max-h-64 gap-3 overflow-y-auto py-3">
|
||||
{view.customerNames
|
||||
.filter((c) => c.includes(customerSearch))
|
||||
.map((c) => (
|
||||
<Field orientation="horizontal" key={c}>
|
||||
<Checkbox
|
||||
id={`customer-${c}`}
|
||||
checked={filters.customer.includes(c)}
|
||||
onCheckedChange={(checked) =>
|
||||
setFilters((f) => ({
|
||||
...f,
|
||||
customer: checked
|
||||
? [...f.customer, c]
|
||||
: f.customer.filter((x) => x !== c),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<FieldLabel htmlFor={`customer-${c}`}>
|
||||
<PrivateText>{c}</PrivateText>
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ExportButton
|
||||
rows={view.filtered}
|
||||
columns={cols}
|
||||
filename="客户资产"
|
||||
sheetName="客户资产"
|
||||
/>
|
||||
</div>
|
||||
<FilterFields
|
||||
fields={[
|
||||
{
|
||||
label: "品牌",
|
||||
value: filters.brand,
|
||||
values: view.brands,
|
||||
change: (v) => setFilters((f) => ({ ...f, brand: v })),
|
||||
},
|
||||
{
|
||||
label: "部门",
|
||||
value: filters.department,
|
||||
values: view.departments,
|
||||
change: (v) =>
|
||||
setFilters((f) => ({ ...f, department: v, manager: "" })),
|
||||
},
|
||||
{
|
||||
label: "负责人",
|
||||
value: filters.manager,
|
||||
values: unique(
|
||||
view.managersByDepartment
|
||||
.filter(
|
||||
(d) =>
|
||||
!filters.department ||
|
||||
d.department === filters.department,
|
||||
)
|
||||
.flatMap((d) => d.managers),
|
||||
),
|
||||
change: (v) => setFilters((f) => ({ ...f, manager: v })),
|
||||
},
|
||||
{
|
||||
label: "区域",
|
||||
value: filters.region,
|
||||
values: view.regions,
|
||||
change: (v) => setFilters((f) => ({ ...f, region: v })),
|
||||
},
|
||||
]}
|
||||
onReset={() => setFilters(initialCustomer)}
|
||||
/>
|
||||
<Tabs value={chartGroup} onValueChange={setChartGroup}>
|
||||
<TabsList className="min-h-11">
|
||||
<TabsTrigger className="min-h-11 px-3" value="region">区域分布</TabsTrigger>
|
||||
<TabsTrigger className="min-h-11 px-3" value="city">城市分布</TabsTrigger>
|
||||
<TabsTrigger className="min-h-11 px-3" value="province">省份分布</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{chartGroup === "province" ? (
|
||||
<ProvinceChart subject={subject} source="vehicle" />
|
||||
) : (
|
||||
<Chart option={option} label="客户车辆地区分布" />
|
||||
)}
|
||||
<DataGrid rows={view.filtered} columns={cols} label="客户资产" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ProvinceChart({
|
||||
subject,
|
||||
source,
|
||||
}: {
|
||||
subject: string | null;
|
||||
source: "vehicle" | "realtime";
|
||||
}) {
|
||||
const [rows, setRows] = useState<{ name: string; value: number }[] | null>(
|
||||
null,
|
||||
);
|
||||
const [error, setError] = useState("");
|
||||
const [retry, setRetry] = useState(0);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setRows(null);
|
||||
setError("");
|
||||
fetchRegionChart("province", 5, source, subject)
|
||||
.then((v) => {
|
||||
if (active) setRows(v);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setError(e.message || "加载失败");
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [subject, source, retry]);
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
color: APPLE_CHART_COLORS,
|
||||
textStyle: { color: "var(--label-secondary)" },
|
||||
tooltip: { trigger: "item" },
|
||||
legend: {
|
||||
type: "scroll",
|
||||
bottom: 0,
|
||||
textStyle: { color: "var(--label-secondary)" },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: ["35%", "65%"],
|
||||
label: { show: false },
|
||||
data: rows || [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
[rows],
|
||||
);
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<CardDescription>
|
||||
{source === "realtime" ? "实时定位" : "车辆档案"}省份分布 ·
|
||||
当前公司范围,不受表格筛选影响
|
||||
</CardDescription>
|
||||
{error ? (
|
||||
<Failure message={error} retry={() => setRetry((n) => n + 1)} />
|
||||
) : rows ? (
|
||||
<Chart option={option} label="省份分布" />
|
||||
) : (
|
||||
<Skeleton className="h-64" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
const EChart = lazy(() => import("./EChartImpl"));
|
||||
export function Chart(props: { option: EChartsCoreOption; label: string }) {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-64" />}>
|
||||
<EChart {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useMemo, useState, useEffect, lazy, Suspense, useRef } from "react";
|
||||
import { ArrowUpRight, Search, SearchX, X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDemoMode } from "@/components/Blur";
|
||||
import { Count, useMobile } from "./shared";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
const DesktopGrid = lazy(
|
||||
() => import("./DesktopGrid"),
|
||||
) as typeof import("./DesktopGrid").default;
|
||||
export type Column<T> = {
|
||||
key: keyof T & string;
|
||||
label: string;
|
||||
width?: number;
|
||||
private?: boolean;
|
||||
primary?: boolean;
|
||||
action?: (row: T) => void;
|
||||
format?: (value: unknown, row: T) => string;
|
||||
numeric?: boolean;
|
||||
tooltip?: (value: unknown, row: T) => string;
|
||||
};
|
||||
export function DataGrid<T extends object>({
|
||||
rows,
|
||||
columns,
|
||||
label = "明细",
|
||||
search = true,
|
||||
}: {
|
||||
rows: T[];
|
||||
columns: Column<T>[];
|
||||
label?: string;
|
||||
search?: boolean;
|
||||
}) {
|
||||
const mobile = useMobile();
|
||||
const demo = useDemoMode();
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [compact, setCompact] = useState(true);
|
||||
const top = useRef<HTMLDivElement>(null);
|
||||
const pageSize = 20;
|
||||
const display = (row: T, col: Column<T>) =>
|
||||
col.private && demo
|
||||
? "••••••"
|
||||
: col.format
|
||||
? col.format(row[col.key], row)
|
||||
: String(row[col.key] ?? "—");
|
||||
const filtered = useMemo(() => {
|
||||
const term = query.trim().toLocaleLowerCase();
|
||||
return rows.filter(
|
||||
(row) =>
|
||||
!term ||
|
||||
columns.some((col) =>
|
||||
display(row, col).toLocaleLowerCase().includes(term),
|
||||
),
|
||||
);
|
||||
}, [rows, columns, query, demo]);
|
||||
useEffect(() => setPage(0), [query, rows.length]);
|
||||
const pages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const currentPage = Math.min(page, pages - 1);
|
||||
const renderValue = (row: T, col: Column<T>) => {
|
||||
if (col.private && demo) return "••••••";
|
||||
if (col.action)
|
||||
return (
|
||||
<Count
|
||||
value={Number(row[col.key]) || 0}
|
||||
label={`${display(row, columns[0])} ${col.label}`}
|
||||
onClick={() => col.action!(row)}
|
||||
/>
|
||||
);
|
||||
const tooltip = col.tooltip?.(row[col.key], row);
|
||||
return tooltip ? (
|
||||
<span title={tooltip}>{display(row, col)}</span>
|
||||
) : (
|
||||
display(row, col)
|
||||
);
|
||||
};
|
||||
const metrics = columns.slice(1).filter((col) => col.action || col.numeric);
|
||||
const textColumns = columns.slice(1).filter((col) => !metrics.includes(col));
|
||||
const primaryColumns = textColumns.filter(
|
||||
(col, index) => index < 4 || col.primary,
|
||||
);
|
||||
const moreColumns = textColumns.filter(
|
||||
(col) => !primaryColumns.includes(col),
|
||||
);
|
||||
const fields = (row: T, list: Column<T>[]) => (
|
||||
<dl className="asset-record-fields">
|
||||
{list.map((col) => (
|
||||
<div key={col.key} className="min-w-0">
|
||||
<dt>{col.label}</dt>
|
||||
<dd>{renderValue(row, col)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
const changePage = (next: number) => {
|
||||
setPage(next);
|
||||
top.current?.scrollIntoView({ block: "start", behavior: "instant" });
|
||||
};
|
||||
return (
|
||||
<div className="grid min-w-0 gap-3 scroll-mt-4" ref={top}>
|
||||
<div className="asset-grid-toolbar">
|
||||
<p className="text-xs text-[var(--label-secondary)]" role="status">
|
||||
{query
|
||||
? `找到 ${filtered.length} / ${rows.length} 条记录`
|
||||
: `共 ${filtered.length} 条记录`}
|
||||
{columns.some((col) => col.action) && (
|
||||
<span className="ml-3 hidden md:inline">蓝色数量可查看明细</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 max-md:w-full md:flex-nowrap">
|
||||
{search && (rows.length > 8 || query) && (
|
||||
<div className="asset-grid-search">
|
||||
<Search aria-hidden="true" />
|
||||
<Input
|
||||
aria-label={`搜索${label}`}
|
||||
placeholder={`搜索${label}…`}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
{query && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`清空${label}搜索`}
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!mobile && rows.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-pressed={compact}
|
||||
onClick={() => setCompact((value) => !value)}
|
||||
>
|
||||
{compact ? "舒适行高" : "紧凑行高"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!filtered.length ? (
|
||||
<div className="inventory-empty">
|
||||
<SearchX aria-hidden="true" />
|
||||
<strong>暂无符合条件的数据</strong>
|
||||
{query && (
|
||||
<Button variant="outline" onClick={() => setQuery("")}>
|
||||
清空搜索
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : mobile ? (
|
||||
<>
|
||||
{filtered
|
||||
.slice(currentPage * pageSize, (currentPage + 1) * pageSize)
|
||||
.map((row, index) => (
|
||||
<article
|
||||
className="asset-record"
|
||||
key={`${String(row[columns[0].key])}-${currentPage}-${index}`}
|
||||
>
|
||||
<header className="asset-record-head">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-xs text-[var(--label-secondary)]">
|
||||
{columns[0].label}
|
||||
</p>
|
||||
<h4>{renderValue(row, columns[0])}</h4>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--label-secondary)]">
|
||||
{String(currentPage * pageSize + index + 1).padStart(
|
||||
2,
|
||||
"0",
|
||||
)}
|
||||
</span>
|
||||
</header>
|
||||
{primaryColumns.length > 0 && fields(row, primaryColumns)}
|
||||
{metrics.length > 0 && (
|
||||
<div className="asset-record-metrics">
|
||||
{metrics.map((col) => {
|
||||
const value = Number(row[col.key]) || 0;
|
||||
const content = (
|
||||
<>
|
||||
<span className="text-xs text-[var(--label-secondary)]">
|
||||
{col.label}
|
||||
</span>
|
||||
<span className="asset-metric-value">
|
||||
{display(row, col)}
|
||||
{col.action && value > 0 && (
|
||||
<ArrowUpRight aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return col.action &&
|
||||
value > 0 &&
|
||||
!(col.private && demo) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="asset-metric-cell"
|
||||
key={col.key}
|
||||
onClick={() => col.action!(row)}
|
||||
aria-label={`查看${display(row, columns[0])} ${col.label}:${value} 条明细`}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`asset-metric-cell ${value ? "" : "asset-metric-empty"}`}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{moreColumns.length > 0 && (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="border-t border-[var(--border)] px-4"
|
||||
>
|
||||
<AccordionItem value="details" className="border-0">
|
||||
<AccordionTrigger className="min-h-11 text-xs">
|
||||
更多车辆信息 · {moreColumns.length} 项
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{fields(row, moreColumns)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
{filtered.length > pageSize && (
|
||||
<nav
|
||||
className="flex items-center justify-between gap-2"
|
||||
aria-label={`${label}分页`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={currentPage === 0}
|
||||
onClick={() => changePage(currentPage - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-xs text-[var(--label-secondary)]">
|
||||
{currentPage + 1} / {pages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={currentPage + 1 >= pages}
|
||||
onClick={() => changePage(currentPage + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
filtered.length > pageSize ? "h-[36rem] min-w-0" : "min-w-0"
|
||||
}
|
||||
role="region"
|
||||
aria-label={label}
|
||||
>
|
||||
<Suspense fallback={<Skeleton className="h-96" />}>
|
||||
<DesktopGrid rows={filtered} columns={columns} compact={compact} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import {
|
||||
AllCommunityModule,
|
||||
ModuleRegistry,
|
||||
themeQuartz,
|
||||
type ColDef,
|
||||
type ICellRendererParams,
|
||||
} from "ag-grid-community";
|
||||
import { useDemoMode } from "@/components/Blur";
|
||||
import { Count } from "./shared";
|
||||
import type { Column } from "./DataGrid";
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
const gridTheme = themeQuartz.withParams({
|
||||
fontFamily: "inherit",
|
||||
fontSize: 14,
|
||||
rowHeight: 48,
|
||||
headerHeight: 36,
|
||||
spacing: 6,
|
||||
borderRadius: 8,
|
||||
accentColor: "var(--blue)",
|
||||
backgroundColor: "var(--bg-tertiary)",
|
||||
foregroundColor: "var(--label)",
|
||||
headerBackgroundColor: "var(--bg-secondary)",
|
||||
borderColor: "var(--separator)",
|
||||
oddRowBackgroundColor: "var(--bg-secondary)",
|
||||
});
|
||||
const localeText = {
|
||||
noRowsToShow: "暂无数据",
|
||||
page: "页",
|
||||
of: "/",
|
||||
to: "–",
|
||||
more: "更多",
|
||||
nextPage: "下一页",
|
||||
previousPage: "上一页",
|
||||
firstPage: "首页",
|
||||
lastPage: "末页",
|
||||
pageSizeSelectorLabel: "每页",
|
||||
loadingOoo: "加载中…",
|
||||
filterOoo: "筛选…",
|
||||
equals: "等于",
|
||||
notEqual: "不等于",
|
||||
contains: "包含",
|
||||
notContains: "不包含",
|
||||
startsWith: "开头是",
|
||||
endsWith: "结尾是",
|
||||
blank: "空白",
|
||||
notBlank: "非空",
|
||||
lessThan: "小于",
|
||||
greaterThan: "大于",
|
||||
inRange: "范围",
|
||||
andCondition: "且",
|
||||
orCondition: "或",
|
||||
};
|
||||
export default function DesktopGrid<T extends object>({
|
||||
rows,
|
||||
columns,
|
||||
compact = false,
|
||||
}: {
|
||||
rows: T[];
|
||||
columns: Column<T>[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const demo = useDemoMode();
|
||||
const display = (row: T, col: Column<T>) =>
|
||||
col.private && demo
|
||||
? "••••••"
|
||||
: col.format
|
||||
? col.format(row[col.key], row)
|
||||
: String(row[col.key] ?? "—");
|
||||
const defs: ColDef<T>[] = columns.map((col, index) => {
|
||||
const numeric = Boolean(col.numeric || col.action);
|
||||
return {
|
||||
colId: col.key,
|
||||
pinned: index === 0 ? "left" : col.key === "total" ? "right" : undefined,
|
||||
headerName: col.label,
|
||||
minWidth: col.width || 110,
|
||||
initialFlex: 1,
|
||||
cellStyle: numeric ? { textAlign: "right" } : undefined,
|
||||
cellClass: numeric ? "tabular-nums" : undefined,
|
||||
headerClass: numeric ? "tabular-nums ag-numeric-header" : undefined,
|
||||
valueGetter: ({ data }) =>
|
||||
data ? (demo && col.private ? "••••••" : data[col.key]) : "",
|
||||
valueFormatter: ({ data }) => (data ? display(data, col) : ""),
|
||||
cellRenderer: col.action
|
||||
? (p: ICellRendererParams<T>) =>
|
||||
p.data && (
|
||||
<Count
|
||||
label={`${display(p.data, columns[0])} ${col.label}`}
|
||||
value={Number(p.data[col.key]) || 0}
|
||||
onClick={() => col.action!(p.data!)}
|
||||
/>
|
||||
)
|
||||
: col.tooltip
|
||||
? (p: ICellRendererParams<T>) =>
|
||||
p.data && (
|
||||
<span
|
||||
title={
|
||||
demo && col.private
|
||||
? undefined
|
||||
: col.tooltip!(p.data[col.key], p.data)
|
||||
}
|
||||
>
|
||||
{display(p.data, col)}
|
||||
</span>
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<AgGridReact<T>
|
||||
theme={gridTheme}
|
||||
rowHeight={compact ? 36 : 48}
|
||||
tooltipShowDelay={350}
|
||||
rowData={rows}
|
||||
columnDefs={defs}
|
||||
defaultColDef={{ sortable: true, filter: true, resizable: true }}
|
||||
domLayout={rows.length <= 20 ? "autoHeight" : "normal"}
|
||||
pagination={rows.length > 20}
|
||||
paginationPageSize={20}
|
||||
paginationPageSizeSelector={[20, 50, 100]}
|
||||
localeText={localeText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as echarts from "echarts/core";
|
||||
import { BarChart, LineChart, PieChart } from "echarts/charts";
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
AriaComponent,
|
||||
DataZoomComponent,
|
||||
} from "echarts/components";
|
||||
import { SVGRenderer } from "echarts/renderers";
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
echarts.use([
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
AriaComponent,
|
||||
DataZoomComponent,
|
||||
SVGRenderer,
|
||||
]);
|
||||
export default function Chart({
|
||||
option,
|
||||
label,
|
||||
}: {
|
||||
option: EChartsCoreOption;
|
||||
label: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const chart = echarts.init(ref.current, undefined, { renderer: "svg" });
|
||||
chart.setOption({ ...option, aria: { enabled: true } });
|
||||
const observer = new ResizeObserver(() => chart.resize());
|
||||
observer.observe(ref.current);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
chart.dispose();
|
||||
};
|
||||
}, [option]);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className="h-64 min-w-0 w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { CalendarDays, Download } from "lucide-react";
|
||||
import {
|
||||
fetchFlowStats,
|
||||
type FlowStatsResponse,
|
||||
type FlowDetailItem,
|
||||
type FlowDailyPoint,
|
||||
} from "../api";
|
||||
import {
|
||||
getRecentFlowRange,
|
||||
getWeeklyFlowRange,
|
||||
formatLocalDate,
|
||||
selectFlowDetails,
|
||||
type FlowSelection,
|
||||
} from "../model";
|
||||
import { Failure, useMobile } from "./shared";
|
||||
import { DataGrid, type Column } from "./DataGrid";
|
||||
import { exportToXlsx } from "./export";
|
||||
import { Chart } from "./Chart";
|
||||
export function FlowStatistics({ subject }: { subject: string | null }) {
|
||||
const [range, setRange] = useState(getRecentFlowRange);
|
||||
const [calendarOpen, setCalendarOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<{ from: Date | undefined; to?: Date }>({
|
||||
from: new Date(`${range.start}T00:00:00`),
|
||||
to: new Date(`${range.end}T00:00:00`),
|
||||
});
|
||||
const [data, setData] = useState<FlowStatsResponse | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [retry, setRetry] = useState(0);
|
||||
const [selection, setSelection] = useState<FlowSelection | null>(null);
|
||||
const mobile = useMobile();
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setData(null);
|
||||
setError("");
|
||||
fetchFlowStats({ ...range, subject })
|
||||
.then((v) => {
|
||||
if (active) setData(v);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setError(String(e.message || e));
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [range, subject, retry]);
|
||||
const details = data && selection ? selectFlowDetails(data, selection) : [];
|
||||
const columns: Column<FlowDetailItem>[] = [
|
||||
{ key: "plateNumber", label: "车牌", private: true },
|
||||
{ key: "typeLabel", label: "类型" },
|
||||
{ key: "date", label: "业务日期" },
|
||||
{ key: "eventTime", label: "运维签章时间", width: 180 },
|
||||
{ key: "submitTime", label: "建单时间", width: 180 },
|
||||
{ key: "department", label: "部门" },
|
||||
{ key: "manager", label: "负责人", private: true },
|
||||
{ key: "customerName", label: "客户", private: true, width: 180 },
|
||||
];
|
||||
const exportRows = (rows: FlowDetailItem[], filename: string) =>
|
||||
exportToXlsx(rows, columns, filename, "交还车明细");
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
color: ["var(--blue)", "var(--orange)", "var(--indigo)"],
|
||||
textStyle: { color: "var(--label-secondary)" },
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: {
|
||||
data: ["交车", "还车", "替换交车"],
|
||||
textStyle: { color: "var(--label-secondary)" },
|
||||
},
|
||||
grid: { left: 40, right: 20, bottom: 40, top: 40 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: data?.daily.map((d) => d.date.slice(5)) || [],
|
||||
axisLabel: { color: "var(--label-secondary)" },
|
||||
axisLine: { lineStyle: { color: "var(--separator)" } },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
minInterval: 1,
|
||||
axisLabel: { color: "var(--label-secondary)" },
|
||||
splitLine: { lineStyle: { color: "var(--separator)" } },
|
||||
},
|
||||
series: ["delivered", "returned", "replaced"].map((key, i) => ({
|
||||
name: ["交车", "还车", "替换交车"][i],
|
||||
type: "line",
|
||||
data: data?.daily.map((d) => d[key as "delivered"]) || [],
|
||||
})),
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
return (
|
||||
<Card
|
||||
size="sm"
|
||||
className="bg-[var(--bg-tertiary)] ring-0 shadow-[var(--shadow-2)]"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-semibold group-data-[size=sm]/card:text-base text-[var(--label)] dark:text-[var(--cyan)]">
|
||||
交还车统计(车次)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
按运维签章时间统计;交车含替换交车,合计 = 交车 + 还车。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Popover
|
||||
open={calendarOpen}
|
||||
onOpenChange={(v) => {
|
||||
setCalendarOpen(v);
|
||||
if (v)
|
||||
setDraft({
|
||||
from: new Date(`${range.start}T00:00:00`),
|
||||
to: new Date(`${range.end}T00:00:00`),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<CalendarDays data-icon="inline-start" />
|
||||
{range.start} — {range.end}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-2" align="start">
|
||||
<Calendar
|
||||
mode="range"
|
||||
locale={zhCN}
|
||||
selected={draft}
|
||||
onSelect={(v) => setDraft(v || { from: undefined })}
|
||||
numberOfMonths={mobile ? 1 : 2}
|
||||
disabled={{ after: new Date() }}
|
||||
/>
|
||||
<Button
|
||||
disabled={!draft.from || !draft.to}
|
||||
onClick={() => {
|
||||
if (draft.from && draft.to)
|
||||
setRange({
|
||||
start: formatLocalDate(draft.from),
|
||||
end: formatLocalDate(draft.to),
|
||||
});
|
||||
setCalendarOpen(false);
|
||||
}}
|
||||
>
|
||||
应用日期
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setRange(getRecentFlowRange())}
|
||||
>
|
||||
近 7 天
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setRange(getWeeklyFlowRange())}
|
||||
>
|
||||
本周
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!data?.details.length}
|
||||
onClick={() =>
|
||||
data &&
|
||||
void exportRows(
|
||||
data.details,
|
||||
`交还车-${range.start}-${range.end}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Download data-icon="inline-start" />
|
||||
导出
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<Failure message={error} retry={() => setRetry((n) => n + 1)} />
|
||||
) : !data ? (
|
||||
<Skeleton className="h-64" />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{(
|
||||
[
|
||||
["total", "合计"],
|
||||
["delivered", "交车"],
|
||||
["returned", "还车"],
|
||||
["replaced", "替换交车"],
|
||||
] as const
|
||||
).map(([key, title]) => (
|
||||
<Card
|
||||
size="sm"
|
||||
key={key}
|
||||
className="bg-[var(--bg-secondary)] ring-0"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardDescription>{title}</CardDescription>
|
||||
<CardTitle className="text-xl font-semibold tracking-tight text-[var(--blue)] dark:text-[var(--cyan)]">
|
||||
{key === "total" ? (
|
||||
data.totals[key]
|
||||
) : (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 text-xl font-semibold text-[var(--blue)] dark:text-[var(--cyan)]"
|
||||
onClick={() => setSelection({ ...range, type: key })}
|
||||
>
|
||||
{data.totals[key]}
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Chart option={option} label="每日交还车趋势" />
|
||||
<DataGrid
|
||||
rows={data.daily}
|
||||
label="每日交还车"
|
||||
columns={[
|
||||
{ key: "date", label: "业务日期", width: 160 },
|
||||
{ key: "total", label: "合计", numeric: true },
|
||||
...(["delivered", "returned", "replaced"] as const).map(
|
||||
(key, i) => ({
|
||||
key,
|
||||
label: ["交车", "还车", "替换交车"][i],
|
||||
action: (row: FlowDailyPoint) =>
|
||||
setSelection({ date: row.date, type: key }),
|
||||
}),
|
||||
),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
<Dialog open={!!selection} onOpenChange={(v) => !v && setSelection(null)}>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-6xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>交还车明细</DialogTitle>
|
||||
<DialogDescription>
|
||||
{details.length} 车次 ·{" "}
|
||||
{selection &&
|
||||
("date" in selection
|
||||
? selection.date
|
||||
: `${selection.start} — ${selection.end}`)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!details.length}
|
||||
onClick={() =>
|
||||
void exportRows(
|
||||
details,
|
||||
`交还车明细-${selection && "date" in selection ? selection.date : `${selection?.start}-${selection?.end}`}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
导出当前明细
|
||||
</Button>
|
||||
<DataGrid rows={details} columns={columns} label="交还车明细" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { ArrowLeft, ArrowUpRight, X, AlertTriangle, Truck } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
inventoryModel,
|
||||
inventoryTotals,
|
||||
scopedInventorySelection,
|
||||
type InventoryScope,
|
||||
} from "./inventory-view";
|
||||
import { VehicleDetails } from "./VehicleDetails";
|
||||
import type { VehicleModalSelection } from "../model";
|
||||
import type { RegionalInventoryStats } from "../types";
|
||||
import { emptyInventoryCounts } from "../../../shared/assets/inventory";
|
||||
|
||||
export function InventoryAnalysis({
|
||||
scope,
|
||||
subject,
|
||||
close,
|
||||
}: {
|
||||
scope: InventoryScope;
|
||||
subject: string | null;
|
||||
close: () => void;
|
||||
}) {
|
||||
const [selection, setSelection] = useState<VehicleModalSelection | null>(
|
||||
() =>
|
||||
scope.initialCategory
|
||||
? scopedInventorySelection(scope, { category: scope.initialCategory })
|
||||
: null,
|
||||
);
|
||||
const [query, setQuery] = useState("");
|
||||
const originFocus = useRef(
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null,
|
||||
);
|
||||
const scrollTop = useRef(0);
|
||||
const total = inventoryTotals(scope.rows);
|
||||
const open = (extra: Partial<VehicleModalSelection> = {}) =>
|
||||
setSelection(scopedInventorySelection(scope, extra));
|
||||
const segments = [
|
||||
{
|
||||
key: "available" as const,
|
||||
label: "可运营",
|
||||
count: total.available,
|
||||
color: "var(--blue)",
|
||||
},
|
||||
{
|
||||
key: "awaiting" as const,
|
||||
label: "待运营",
|
||||
count: total.awaiting,
|
||||
color: "var(--inventory-awaiting)",
|
||||
},
|
||||
...(total.unclassified
|
||||
? [
|
||||
{
|
||||
key: "unclassified" as const,
|
||||
label: "待核实状态",
|
||||
count: total.unclassified,
|
||||
color: "var(--label-secondary)",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const variants = useMemo(() => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
rows: RegionalInventoryStats[];
|
||||
color: string;
|
||||
count: number;
|
||||
name: string;
|
||||
brand: string;
|
||||
operator: string;
|
||||
}
|
||||
>();
|
||||
for (const row of scope.rows) {
|
||||
const model = inventoryModel(row.model, row.brand);
|
||||
const colors = row.variants || {
|
||||
未标注颜色: { ...emptyInventoryCounts(), quantity: row.quantity },
|
||||
};
|
||||
for (const [color, counts] of Object.entries(colors)) {
|
||||
const key = JSON.stringify([row.brand, row.model, color]);
|
||||
const value = groups.get(key) || {
|
||||
rows: [],
|
||||
color,
|
||||
count: 0,
|
||||
name: model.name,
|
||||
brand: row.brand,
|
||||
operator: model.operator,
|
||||
};
|
||||
value.rows.push(row);
|
||||
value.count += counts.quantity;
|
||||
groups.set(key, value);
|
||||
}
|
||||
}
|
||||
return [...groups.entries()].sort((a, b) => b[1].count - a[1].count);
|
||||
}, [scope.rows]);
|
||||
const shown = variants.filter(([, item]) =>
|
||||
[item.name, item.brand, item.operator, item.color]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query.trim().toLowerCase()),
|
||||
);
|
||||
let offset = 0;
|
||||
return (
|
||||
<>
|
||||
<Dialog open={!selection} onOpenChange={(value) => !value && close()}>
|
||||
<DialogContent
|
||||
className="asset-ui asset-detail inventory-analysis"
|
||||
showCloseButton={false}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
if (!selection) originFocus.current?.focus({ preventScroll: true });
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="asset-detail-header">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={close}
|
||||
aria-label="返回库存统计"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="analysis-eyebrow">库存分析</p>
|
||||
<DialogTitle>库存构成</DialogTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={close}
|
||||
aria-label="关闭库存分析"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
{subject || "全部公司"} · {scope.label}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div
|
||||
className="asset-detail-body"
|
||||
ref={(node) => {
|
||||
if (node) node.scrollTop = scrollTop.current;
|
||||
}}
|
||||
onScroll={(event) => {
|
||||
scrollTop.current = event.currentTarget.scrollTop;
|
||||
}}
|
||||
>
|
||||
<div className="analysis-overview">
|
||||
<section
|
||||
className="analysis-composition"
|
||||
aria-label="运营状态构成"
|
||||
>
|
||||
<div className="analysis-donut">
|
||||
<svg
|
||||
viewBox="0 0 200 200"
|
||||
role="group"
|
||||
aria-label="库存运营状态占比"
|
||||
>
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="78"
|
||||
fill="none"
|
||||
stroke="var(--muted)"
|
||||
strokeWidth="20"
|
||||
/>
|
||||
{segments
|
||||
.filter((segment) => segment.count > 0)
|
||||
.map((segment) => {
|
||||
const proportion = segment.count / total.quantity;
|
||||
const dashOffset = -offset;
|
||||
offset += proportion * 100;
|
||||
return (
|
||||
<circle
|
||||
key={segment.key}
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="78"
|
||||
pathLength="100"
|
||||
fill="none"
|
||||
stroke={segment.color}
|
||||
strokeWidth="20"
|
||||
strokeDasharray={`${proportion * 100} ${100 - proportion * 100}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
transform="rotate(-90 100 100)"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${segment.label} ${segment.count} 台,查看明细`}
|
||||
onClick={() =>
|
||||
open({
|
||||
inventoryOperation: segment.key,
|
||||
title: `${segment.label}车辆`,
|
||||
})
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
open({
|
||||
inventoryOperation: segment.key,
|
||||
title: `${segment.label}车辆`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<title>
|
||||
{segment.label} {segment.count} 台 ·{" "}
|
||||
{(proportion * 100).toFixed(1)}%
|
||||
</title>
|
||||
</circle>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="analysis-donut-center">
|
||||
<span>总库存</span>
|
||||
<strong>{total.quantity.toLocaleString()}</strong>
|
||||
<small>台</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="analysis-legend">
|
||||
<h3>运营状态</h3>
|
||||
{segments.map((segment) => (
|
||||
<button
|
||||
key={segment.key}
|
||||
disabled={!segment.count}
|
||||
onClick={() =>
|
||||
open({
|
||||
inventoryOperation: segment.key,
|
||||
title: `${segment.label}车辆`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="analysis-dot"
|
||||
style={{ background: segment.color }}
|
||||
/>
|
||||
<span>{segment.label}</span>
|
||||
<strong>{segment.count}</strong>
|
||||
<span>
|
||||
{total.quantity
|
||||
? ((segment.count / total.quantity) * 100).toFixed(1)
|
||||
: "0.0"}
|
||||
%
|
||||
</span>
|
||||
<ArrowUpRight aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
<p>
|
||||
{total.unclassified
|
||||
? "运营状态按原始字段分别统计"
|
||||
: "可运营 + 待运营 = 总库存"}
|
||||
</p>
|
||||
{total.unclassified > 0 && (
|
||||
<p role="status">
|
||||
存在旧状态,单独列示待核实,未计入可运营或待运营。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="analysis-workflow" aria-label="库存业务状态">
|
||||
<div className="analysis-workflow-heading">
|
||||
<h3>业务状态</h3>
|
||||
<span>已包含在总库存内</span>
|
||||
</div>
|
||||
<button
|
||||
className="analysis-workflow-total"
|
||||
disabled={!total.abnormal}
|
||||
onClick={() => open({ category: "Abnormal" })}
|
||||
>
|
||||
<span>
|
||||
<AlertTriangle aria-hidden="true" />
|
||||
异动
|
||||
</span>
|
||||
<strong>
|
||||
{total.abnormal}
|
||||
<small>台</small>
|
||||
<ArrowUpRight aria-hidden="true" />
|
||||
</strong>
|
||||
</button>
|
||||
<div className="analysis-abnormal-types">
|
||||
{Object.entries(total.abnormalTypes).map(([type, count]) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() =>
|
||||
open({
|
||||
category: "Abnormal",
|
||||
abnormalType: type,
|
||||
title: `${type}异动车辆`,
|
||||
})
|
||||
}
|
||||
>
|
||||
{type}
|
||||
<strong>{count}</strong>
|
||||
</button>
|
||||
))}
|
||||
{!total.abnormal && <span>当前范围无异动车辆</span>}
|
||||
</div>
|
||||
<button
|
||||
className="analysis-workflow-total"
|
||||
disabled={!total.pending}
|
||||
onClick={() => open({ category: "Pending" })}
|
||||
>
|
||||
<span>
|
||||
<Truck aria-hidden="true" />
|
||||
待交车
|
||||
</span>
|
||||
<strong>
|
||||
{total.pending}
|
||||
<small>台</small>
|
||||
<ArrowUpRight aria-hidden="true" />
|
||||
</strong>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
<section className="analysis-variants" aria-label="型号及颜色明细">
|
||||
<div className="analysis-variants-heading">
|
||||
<div>
|
||||
<h3>型号明细</h3>
|
||||
<p>品牌 · 所属公司 · 颜色</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => open()}>
|
||||
查看全部车辆
|
||||
<ArrowUpRight aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
aria-label="搜索型号明细"
|
||||
placeholder="搜索型号、品牌、所属公司或颜色"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<div className="analysis-variant-list">
|
||||
{shown.map(([key, item]) => (
|
||||
<button
|
||||
className="analysis-variant"
|
||||
key={key}
|
||||
onClick={() =>
|
||||
setSelection(
|
||||
scopedInventorySelection(
|
||||
{
|
||||
rows: item.rows,
|
||||
label: `${scope.label} / ${item.name} / ${item.brand}${item.operator ? ` / ${item.operator}` : ""} / ${item.color}`,
|
||||
},
|
||||
{ inventoryColor: item.color },
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<strong>{item.name}</strong>
|
||||
<div className="analysis-tags">
|
||||
<span>{item.brand}</span>
|
||||
{item.operator && <span>{item.operator}</span>}
|
||||
<span>{item.color}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="analysis-variant-count">
|
||||
{item.count}
|
||||
<small>台</small>
|
||||
<ArrowUpRight aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{!shown.length && (
|
||||
<p className="matrix-empty">没有匹配的型号</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<footer className="asset-detail-footer">
|
||||
<span>点击数量查看当前范围的车辆明细</span>
|
||||
<Button variant="outline" onClick={close}>
|
||||
返回库存统计
|
||||
</Button>
|
||||
</footer>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<VehicleDetails
|
||||
selection={selection}
|
||||
subject={subject}
|
||||
close={() => setSelection(null)}
|
||||
returnLabel="返回库存构成"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { inventoryColor } from "../../../shared/assets/inventory";
|
||||
import { matchesInventorySelection, inventoryModel } from "./inventory-view";
|
||||
import { ArrowLeft, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
fetchVehicleList,
|
||||
fetchWeeklyDetail,
|
||||
type WeeklyDetailItem,
|
||||
} from "../api";
|
||||
import { buildVehicleModalRequest, type VehicleModalSelection } from "../model";
|
||||
import type { VehicleListItem } from "../types";
|
||||
import { DataGrid, type Column } from "./DataGrid";
|
||||
import { ExportButton } from "./export";
|
||||
import { Failure, FilterFields, PrivateText, unique } from "./shared";
|
||||
const titles = {
|
||||
Inventory: "库存车辆",
|
||||
Operating: "运营车辆",
|
||||
Abnormal: "异动车辆",
|
||||
Pending: "待交车车辆",
|
||||
Delivered: "本周交车",
|
||||
Returned: "本周还车",
|
||||
Replaced: "本周替换交车",
|
||||
};
|
||||
export function VehicleDetails({
|
||||
selection,
|
||||
subject,
|
||||
close,
|
||||
returnLabel = "返回统计",
|
||||
}: {
|
||||
selection: VehicleModalSelection | null;
|
||||
subject: string | null;
|
||||
close: () => void;
|
||||
returnLabel?: string;
|
||||
}) {
|
||||
const returnFocus = useRef<HTMLElement | null>(null);
|
||||
const [vehicles, setVehicles] = useState<VehicleListItem[]>([]);
|
||||
const [weekly, setWeekly] = useState<WeeklyDetailItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [retry, setRetry] = useState(0);
|
||||
const [filters, setFilters] = useState({
|
||||
model: "",
|
||||
brandLabel: "",
|
||||
location: "",
|
||||
});
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setFilters({ model: "", brandLabel: "", location: "" });
|
||||
setVehicles([]);
|
||||
setWeekly([]);
|
||||
setError("");
|
||||
if (!selection) return;
|
||||
setLoading(true);
|
||||
const request = buildVehicleModalRequest(selection, subject);
|
||||
const run = async () => {
|
||||
try {
|
||||
if (request.kind === "weekly") {
|
||||
const rows = await fetchWeeklyDetail(request.type, {
|
||||
...request.filters,
|
||||
subject,
|
||||
});
|
||||
if (active) setWeekly(rows);
|
||||
} else {
|
||||
const rows = await fetchVehicleList(request.params);
|
||||
if (active)
|
||||
setVehicles(
|
||||
rows.filter((row) => matchesInventorySelection(row, selection)),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (active) setError(e instanceof Error ? e.message : "加载失败");
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [selection, subject, retry]);
|
||||
const rows = vehicles
|
||||
.filter((r) =>
|
||||
Object.entries(filters).every(
|
||||
([k, v]) => !v || r[k as keyof VehicleListItem] === v,
|
||||
),
|
||||
)
|
||||
.map((r) => ({
|
||||
...r,
|
||||
color: inventoryColor(r.color),
|
||||
displayModel: inventoryModel(r.model, r.brandLabel || "").name,
|
||||
operator: inventoryModel(r.model, r.brandLabel || "").operator || "—",
|
||||
pendingContract: r.pendingDelivery?.contractNo || "—",
|
||||
pendingCustomer: r.pendingDelivery?.customerName || "—",
|
||||
moveType: r.abnormalMove?.type || "—",
|
||||
work: r.abnormalMove?.work || "—",
|
||||
target: r.abnormalMove?.targetCompletionTime || "—",
|
||||
actual: r.abnormalMove?.actualCompletionTime || "—",
|
||||
initiating: r.abnormalMove?.initiatingDepartment || "—",
|
||||
executing: r.abnormalMove?.executingDepartment || "—",
|
||||
responsible: r.abnormalMove?.responsiblePerson || "—",
|
||||
driver: r.abnormalMove?.driver || "—",
|
||||
}));
|
||||
type Row = (typeof rows)[number];
|
||||
const columns: Column<Row>[] = [
|
||||
{ key: "plateNumber", label: "车牌", private: true, width: 140 },
|
||||
{ key: "brandLabel", label: "品牌" },
|
||||
{ key: "type", label: "车型" },
|
||||
{ key: "displayModel", label: "型号", width: 160 },
|
||||
{ key: "operator", label: "所属公司" },
|
||||
{ key: "color", label: "颜色" },
|
||||
{ key: "location", label: "所在地" },
|
||||
];
|
||||
if (selection?.category === "Pending")
|
||||
columns.push(
|
||||
{
|
||||
key: "pendingContract",
|
||||
primary: true,
|
||||
label: "待交车合同",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
key: "pendingCustomer",
|
||||
primary: true,
|
||||
label: "客户(已脱敏)",
|
||||
width: 180,
|
||||
private: true,
|
||||
},
|
||||
);
|
||||
if (selection?.category === "Abnormal")
|
||||
columns.push(
|
||||
...([
|
||||
{ key: "moveType", primary: true, label: "异动类型" },
|
||||
{ key: "target", primary: true, label: "预计结束时间", width: 180 },
|
||||
{ key: "work", label: "具体工作", width: 220 },
|
||||
{ key: "initiating", label: "发起部门" },
|
||||
{ key: "executing", label: "执行部门" },
|
||||
{ key: "responsible", label: "负责人", private: true },
|
||||
{ key: "driver", label: "操作司机", private: true },
|
||||
{ key: "actual", label: "实际完成时间", width: 180 },
|
||||
] as Column<Row>[]),
|
||||
);
|
||||
columns.push(
|
||||
{
|
||||
key: "operationStatus",
|
||||
label: "运营状态",
|
||||
primary: true,
|
||||
format: (value) =>
|
||||
({ "1": "租赁", "2": "自营", "3": "可运营", "4": "待运营" })[
|
||||
String(value)
|
||||
] || String(value || "未标注"),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "业务状态",
|
||||
primary: true,
|
||||
format: (value) =>
|
||||
({
|
||||
Inventory: "库存",
|
||||
Operating: "运营",
|
||||
Abnormal: "异动",
|
||||
Pending: "待交车",
|
||||
})[String(value)] || String(value),
|
||||
},
|
||||
{ key: "departmentName", label: "部门" },
|
||||
{ key: "customerManager", label: "客户经理", private: true },
|
||||
{ key: "vin", label: "车架号", private: true, width: 200 },
|
||||
);
|
||||
const isWeekly =
|
||||
selection &&
|
||||
["Delivered", "Returned", "Replaced"].includes(selection.category || "");
|
||||
const weeklyColumns: Column<WeeklyDetailItem>[] = [
|
||||
{ key: "plate_number", label: "车牌", private: true },
|
||||
{ key: "handover_date", label: "签章日期", width: 180 },
|
||||
{ key: "contract_type", label: "合同类型" },
|
||||
{ key: "customer_name", label: "客户", private: true },
|
||||
];
|
||||
const title =
|
||||
selection?.title ||
|
||||
(selection?.category ? titles[selection.category] : "车辆明细");
|
||||
const context = [
|
||||
["城市 / 区域", selection?.location],
|
||||
["品牌", selection?.brand],
|
||||
["车型", selection?.type || selection?.vehicleType],
|
||||
["型号", selection?.model],
|
||||
["批次", selection?.batch],
|
||||
["部门", selection?.department],
|
||||
].filter(([, value]) => value && value !== "All");
|
||||
return (
|
||||
<Dialog open={!!selection} onOpenChange={(v) => !v && close()}>
|
||||
<DialogContent
|
||||
className="asset-ui asset-detail"
|
||||
showCloseButton={false}
|
||||
onOpenAutoFocus={() => {
|
||||
returnFocus.current =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (returnFocus.current?.isConnected) {
|
||||
event.preventDefault();
|
||||
returnFocus.current.focus({ preventScroll: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="asset-detail-header">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={close}
|
||||
aria-label={returnLabel}
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="mb-1 text-xs text-[var(--label-secondary)]">
|
||||
资产管理 /{" "}
|
||||
{selection?.source === "department"
|
||||
? "部门运营"
|
||||
: selection?.source === "region"
|
||||
? "区域分布"
|
||||
: selection?.source === "customer"
|
||||
? "客户资产"
|
||||
: "资产统计"}
|
||||
</p>
|
||||
<DialogTitle className="text-lg font-semibold">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden md:inline-flex"
|
||||
onClick={close}
|
||||
aria-label="关闭车辆明细"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
{subject || "全部公司"} ·{" "}
|
||||
{loading
|
||||
? "正在加载车辆…"
|
||||
: `${isWeekly ? weekly.length : vehicles.length} 条记录`}
|
||||
</DialogDescription>
|
||||
{selection?.scopeLabel && (
|
||||
<p className="text-sm">{selection.scopeLabel}</p>
|
||||
)}
|
||||
{context.length > 0 && (
|
||||
<div className="asset-context" aria-label="当前下钻范围">
|
||||
{context.map(([label, value]) => (
|
||||
<span key={label}>
|
||||
{label}:{value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selection?.customer && (
|
||||
<p className="text-xs text-[var(--label-secondary)]">
|
||||
客户:<PrivateText>{selection.customer}</PrivateText>
|
||||
</p>
|
||||
)}
|
||||
{selection?.manager && (
|
||||
<p className="text-xs text-[var(--label-secondary)]">
|
||||
负责人:<PrivateText>{selection.manager}</PrivateText>
|
||||
</p>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<div className="asset-detail-body" aria-busy={loading}>
|
||||
{error ? (
|
||||
<Failure message={error} retry={() => setRetry((n) => n + 1)} />
|
||||
) : loading ? (
|
||||
<Skeleton className="h-64" />
|
||||
) : isWeekly ? (
|
||||
<>
|
||||
<ExportButton
|
||||
rows={weekly}
|
||||
columns={weeklyColumns}
|
||||
filename="交还车车辆"
|
||||
sheetName="交还车车辆"
|
||||
/>
|
||||
<DataGrid
|
||||
rows={weekly}
|
||||
label="交还车车辆"
|
||||
columns={weeklyColumns}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FilterFields
|
||||
fields={(["model", "brandLabel", "location"] as const).map(
|
||||
(key, i) => ({
|
||||
label: ["型号", "品牌", "所在地"][i],
|
||||
value: filters[key],
|
||||
values: unique(vehicles.map((r) => r[key])),
|
||||
change: (value) =>
|
||||
setFilters((f) => ({ ...f, [key]: value })),
|
||||
}),
|
||||
)}
|
||||
onReset={() =>
|
||||
setFilters({ model: "", brandLabel: "", location: "" })
|
||||
}
|
||||
/>
|
||||
<ExportButton
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
filename="车辆明细"
|
||||
sheetName="车辆"
|
||||
/>
|
||||
<DataGrid rows={rows} columns={columns} label="车辆" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<footer className="asset-detail-footer">
|
||||
<span>关闭后返回原来的筛选与浏览位置</span>
|
||||
<Button variant="outline" onClick={close}>
|
||||
{returnLabel}
|
||||
</Button>
|
||||
</footer>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Column } from "./DataGrid";
|
||||
|
||||
function cellValue<T extends object>(row: T, col: Column<T>): string | number {
|
||||
const raw = row[col.key];
|
||||
if (col.format) return col.format(raw, row);
|
||||
return (raw as string | number) ?? "";
|
||||
}
|
||||
|
||||
/** 按表格列定义导出 xlsx;xlsx 按需加载,避免进入首屏包。 */
|
||||
export async function exportToXlsx<T extends object>(
|
||||
rows: T[],
|
||||
columns: Column<T>[],
|
||||
filename: string,
|
||||
sheetName = "明细",
|
||||
): Promise<void> {
|
||||
const XLSX = await import("xlsx");
|
||||
const sheet = XLSX.utils.json_to_sheet(
|
||||
rows.map((row) =>
|
||||
Object.fromEntries(columns.map((col) => [col.label, cellValue(row, col)])),
|
||||
),
|
||||
);
|
||||
const book = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(book, sheet, sheetName);
|
||||
XLSX.writeFile(book, `${filename}.xlsx`);
|
||||
}
|
||||
|
||||
export function ExportButton<T extends object>({
|
||||
rows,
|
||||
columns,
|
||||
filename,
|
||||
sheetName,
|
||||
label = "导出",
|
||||
}: {
|
||||
rows: T[];
|
||||
columns: Column<T>[];
|
||||
filename: string;
|
||||
sheetName?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9 w-fit md:min-h-7"
|
||||
disabled={!rows.length}
|
||||
onClick={() => void exportToXlsx(rows, columns, filename, sheetName)}
|
||||
>
|
||||
<Download data-icon="inline-start" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { RegionalInventoryStats } from "../types";
|
||||
import {
|
||||
inventoryBrowseRows,
|
||||
inventoryBrowseUrl,
|
||||
inventoryModels,
|
||||
inventoryPlaces,
|
||||
inventoryTypeRows,
|
||||
readInventoryBrowse,
|
||||
} from "./inventory-explorer";
|
||||
import {
|
||||
INVENTORY_TYPES,
|
||||
inventoryTotals,
|
||||
scopedInventorySelection,
|
||||
} from "./inventory-view";
|
||||
import { inventoryKey } from "../../../shared/assets/inventory";
|
||||
|
||||
const make = (
|
||||
changes: Partial<RegionalInventoryStats> = {},
|
||||
): RegionalInventoryStats => ({
|
||||
region: "华东",
|
||||
city: "嘉兴市",
|
||||
brand: "现代",
|
||||
type: "4.5T冷链",
|
||||
model: "冷链(公司甲)",
|
||||
batch: "批次甲",
|
||||
quantity: 5,
|
||||
available: 3,
|
||||
awaiting: 2,
|
||||
pending: 1,
|
||||
abnormal: 1,
|
||||
...changes,
|
||||
});
|
||||
|
||||
test("region, city and type partitions reconcile without counting pending/abnormal twice", () => {
|
||||
const rows = [
|
||||
make(),
|
||||
make({ city: "杭州市", quantity: 3 }),
|
||||
make({ region: "华南", city: "广州市", type: "49T", quantity: 7 }),
|
||||
make({ type: "挂车", quantity: 2 }),
|
||||
];
|
||||
const total = inventoryTotals(rows).quantity;
|
||||
assert.equal(total, 17);
|
||||
assert.equal(
|
||||
inventoryPlaces(rows, "region", "").reduce(
|
||||
(n, place) => n + place.total.quantity,
|
||||
0,
|
||||
),
|
||||
total,
|
||||
);
|
||||
assert.equal(
|
||||
INVENTORY_TYPES.reduce(
|
||||
(n, type) => n + inventoryTotals(inventoryTypeRows(rows, type)).quantity,
|
||||
0,
|
||||
),
|
||||
total,
|
||||
);
|
||||
for (const region of inventoryPlaces(rows, "region", "")) {
|
||||
assert.equal(
|
||||
inventoryPlaces(region.rows, "city", "").reduce(
|
||||
(n, city) => n + city.total.quantity,
|
||||
0,
|
||||
),
|
||||
region.total.quantity,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("type selection keeps zero-stock locations visible and never uses other-type totals", () => {
|
||||
const places = inventoryPlaces(
|
||||
[
|
||||
make(),
|
||||
make({ region: "华南", city: "广州市", type: "49T", quantity: 7 }),
|
||||
],
|
||||
"region",
|
||||
"49T",
|
||||
);
|
||||
assert.deepEqual(
|
||||
places.map((place) => [place.name, place.total.quantity]),
|
||||
[
|
||||
["华南", 7],
|
||||
["华东", 0],
|
||||
],
|
||||
);
|
||||
assert.equal(places[1].rows.length, 0);
|
||||
});
|
||||
|
||||
test("city drill-down intersects geography, brand, query and type before opening exact detail keys", () => {
|
||||
const wanted = make();
|
||||
const rows = [
|
||||
wanted,
|
||||
make({ region: "华南" }),
|
||||
make({ city: "杭州市" }),
|
||||
make({ brand: "另一品牌" }),
|
||||
make({ model: "普货", type: "4.5T普货" }),
|
||||
];
|
||||
const state = {
|
||||
...readInventoryBrowse(""),
|
||||
region: "华东",
|
||||
city: "嘉兴市",
|
||||
brand: "现代",
|
||||
query: "冷链",
|
||||
type: "4.5T冷链",
|
||||
};
|
||||
const filtered = inventoryTypeRows(
|
||||
inventoryBrowseRows(rows, state),
|
||||
state.type,
|
||||
);
|
||||
assert.deepEqual(filtered, [wanted]);
|
||||
const model = inventoryModels(filtered)[0];
|
||||
assert.deepEqual(
|
||||
scopedInventorySelection({ rows: model.rows, label: "测试范围" })
|
||||
.inventoryKeys,
|
||||
[inventoryKey(wanted)],
|
||||
);
|
||||
});
|
||||
|
||||
test("missing geography and duplicate city/model names retain exact identity", () => {
|
||||
const rows = [
|
||||
make({ region: "", city: "" }),
|
||||
make({ city: "" }),
|
||||
make({ region: "华南", city: "" }),
|
||||
];
|
||||
assert.equal(inventoryPlaces(rows, "city", "").length, 3);
|
||||
assert.deepEqual(
|
||||
inventoryBrowseRows(rows, {
|
||||
...readInventoryBrowse(""),
|
||||
region: "",
|
||||
city: "",
|
||||
}),
|
||||
[rows[0]],
|
||||
);
|
||||
const models = inventoryModels([
|
||||
make({ type: "挂车" }),
|
||||
make({ type: "35T" }),
|
||||
make({ brand: "另一品牌" }),
|
||||
]);
|
||||
assert.equal(models.length, 3);
|
||||
assert.equal(new Set(models.map((model) => model.key)).size, 3);
|
||||
});
|
||||
|
||||
test("deep links round-trip filters, empty geography and model identity without changing the host route", () => {
|
||||
const state = {
|
||||
region: "",
|
||||
city: "",
|
||||
type: "其他",
|
||||
model: '["现代","挂车","A/B"]',
|
||||
query: "A/B & C",
|
||||
brand: "现代",
|
||||
};
|
||||
const url = new URL(
|
||||
inventoryBrowseUrl(
|
||||
"https://example.test/asset?existing=yes#assets/overview",
|
||||
state,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(readInventoryBrowse(url.search), state);
|
||||
assert.equal(url.hash, "#assets/overview");
|
||||
assert.equal(url.pathname, "/asset");
|
||||
assert.equal(url.searchParams.get("existing"), "yes");
|
||||
const cleared = new URL(
|
||||
inventoryBrowseUrl(url.href, readInventoryBrowse("")),
|
||||
);
|
||||
assert.equal(cleared.search, "?existing=yes");
|
||||
});
|
||||
|
||||
test("orphan child links and invalid type values cannot select an unrelated scope", () => {
|
||||
assert.deepEqual(
|
||||
readInventoryBrowse("?stockCity=嘉兴市&stockModel=x&stockType=invalid"),
|
||||
readInventoryBrowse(""),
|
||||
);
|
||||
const state = readInventoryBrowse("?stockRegion=华东&stockModel=x");
|
||||
assert.equal(state.model, null);
|
||||
assert.equal(inventoryModels([]).length, 0);
|
||||
assert.equal(
|
||||
inventoryBrowseRows([make()], { ...state, query: "no matching model" })
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { RegionalInventoryStats } from "../types";
|
||||
import {
|
||||
INVENTORY_TYPES,
|
||||
inventoryColumn,
|
||||
inventoryTotals,
|
||||
} from "./inventory-view";
|
||||
|
||||
export interface InventoryBrowseState {
|
||||
region: string | null;
|
||||
city: string | null;
|
||||
type: string;
|
||||
model: string | null;
|
||||
query: string;
|
||||
brand: string;
|
||||
}
|
||||
|
||||
export function readInventoryBrowse(search: string): InventoryBrowseState {
|
||||
const params = new URLSearchParams(search);
|
||||
const region = params.get("stockRegion");
|
||||
const city = region === null ? null : params.get("stockCity");
|
||||
const type = params.get("stockType") || "";
|
||||
return {
|
||||
region,
|
||||
city,
|
||||
type: INVENTORY_TYPES.includes(type as (typeof INVENTORY_TYPES)[number])
|
||||
? type
|
||||
: "",
|
||||
model: city === null ? null : params.get("stockModel"),
|
||||
query: params.get("stockQuery") || "",
|
||||
brand: params.get("stockBrand") || "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep the application's route and unrelated URL parameters intact. */
|
||||
export function inventoryBrowseUrl(href: string, state: InventoryBrowseState) {
|
||||
const url = new URL(href);
|
||||
for (const [key, value] of Object.entries({
|
||||
stockRegion: state.region,
|
||||
stockCity: state.region === null ? null : state.city,
|
||||
stockType: state.type || null,
|
||||
stockModel:
|
||||
state.region === null || state.city === null ? null : state.model,
|
||||
stockQuery: state.query || null,
|
||||
stockBrand: state.brand || null,
|
||||
})) {
|
||||
if (value === null) url.searchParams.delete(key);
|
||||
else url.searchParams.set(key, value);
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
|
||||
export function inventoryBrowseRows(
|
||||
rows: RegionalInventoryStats[],
|
||||
state: InventoryBrowseState,
|
||||
) {
|
||||
const query = state.query.trim().toLocaleLowerCase();
|
||||
return rows.filter(
|
||||
(row) =>
|
||||
(state.region === null || row.region === state.region) &&
|
||||
(state.city === null || row.city === state.city) &&
|
||||
(!state.brand || row.brand === state.brand) &&
|
||||
(!query ||
|
||||
[
|
||||
row.region,
|
||||
row.city,
|
||||
row.brand,
|
||||
row.type,
|
||||
row.model,
|
||||
inventoryColumn(row.type),
|
||||
]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase()
|
||||
.includes(query)),
|
||||
);
|
||||
}
|
||||
|
||||
export function inventoryTypeRows(
|
||||
rows: RegionalInventoryStats[],
|
||||
type: string,
|
||||
) {
|
||||
return type ? rows.filter((row) => inventoryColumn(row.type) === type) : rows;
|
||||
}
|
||||
|
||||
/** Build places before applying the type filter so no-stock places remain explicit. */
|
||||
export function inventoryPlaces(
|
||||
rows: RegionalInventoryStats[],
|
||||
level: "region" | "city",
|
||||
type: string,
|
||||
) {
|
||||
const groups = new Map<string, RegionalInventoryStats[]>();
|
||||
for (const row of rows) {
|
||||
const key = JSON.stringify(
|
||||
level === "region" ? [row.region] : [row.region, row.city],
|
||||
);
|
||||
const group = groups.get(key) || [];
|
||||
group.push(row);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return [...groups]
|
||||
.map(([key, records]) => {
|
||||
const selected = inventoryTypeRows(records, type);
|
||||
return {
|
||||
key,
|
||||
value: records[0][level],
|
||||
name:
|
||||
records[0][level] ||
|
||||
(level === "region" ? "未归属地区" : "未归属城市"),
|
||||
rows: selected,
|
||||
total: inventoryTotals(selected),
|
||||
cities: new Set(
|
||||
selected.filter((row) => row.quantity > 0).map((row) => row.city),
|
||||
).size,
|
||||
types: INVENTORY_TYPES.map((name) => ({
|
||||
name,
|
||||
quantity: inventoryTotals(inventoryTypeRows(records, name)).quantity,
|
||||
})).filter((item) => item.quantity > 0),
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.total.quantity - a.total.quantity ||
|
||||
a.name.localeCompare(b.name, "zh-CN"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Keep the exact type/model/brand identity even when displayed in the Other column. */
|
||||
export function inventoryModels(rows: RegionalInventoryStats[]) {
|
||||
const groups = new Map<string, RegionalInventoryStats[]>();
|
||||
for (const row of rows) {
|
||||
const key = JSON.stringify([row.brand, row.type, row.model]);
|
||||
const group = groups.get(key) || [];
|
||||
group.push(row);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return [...groups]
|
||||
.map(([key, records]) => ({
|
||||
key,
|
||||
rows: records,
|
||||
brand: records[0].brand,
|
||||
model: records[0].model,
|
||||
type: records[0].type,
|
||||
total: inventoryTotals(records),
|
||||
}))
|
||||
.filter((group) => group.total.quantity > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.total.quantity - a.total.quantity ||
|
||||
a.model.localeCompare(b.model, "zh-CN"),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
inventoryKey,
|
||||
inventoryOperation,
|
||||
inventoryColor,
|
||||
} from "../../../shared/assets/inventory";
|
||||
import {
|
||||
INVENTORY_TYPES,
|
||||
inventoryColumn,
|
||||
inventoryTotals,
|
||||
inventoryModel,
|
||||
scopedInventorySelection,
|
||||
matchesInventorySelection,
|
||||
} from "./inventory-view";
|
||||
import type { VehicleListItem } from "../types";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { groupInventory, inventorySelection } from "./inventory-view";
|
||||
import { buildVehicleModalRequest } from "../model";
|
||||
import type { RegionalInventoryStats } from "../types";
|
||||
const make = (
|
||||
change: Partial<RegionalInventoryStats> = {},
|
||||
): RegionalInventoryStats => ({
|
||||
region: "华东",
|
||||
city: "嘉兴市",
|
||||
brand: "明威",
|
||||
type: "挂车",
|
||||
model: "挂车",
|
||||
batch: "挂车",
|
||||
quantity: 2,
|
||||
abnormal: 1,
|
||||
pending: 1,
|
||||
...change,
|
||||
});
|
||||
test("inventory grouping preserves totals and does not double-count abnormal/pending subsets", () => {
|
||||
const rows = [
|
||||
make(),
|
||||
make({ brand: "万风", quantity: 1 }),
|
||||
make({ region: "华南", city: "广州市", quantity: 4 }),
|
||||
];
|
||||
const original = JSON.stringify(rows);
|
||||
const groups = groupInventory(rows, "region");
|
||||
assert.deepEqual(
|
||||
groups.map((group) => [group.name, group.total]),
|
||||
[
|
||||
["华南", 4],
|
||||
["华东", 3],
|
||||
],
|
||||
);
|
||||
assert.equal(groups[1].cities[0].rows.length, 2);
|
||||
assert.equal(
|
||||
groups.reduce((sum, group) => sum + group.total, 0),
|
||||
7,
|
||||
);
|
||||
assert.equal(JSON.stringify(rows), original);
|
||||
});
|
||||
test("type grouping keeps same-name cities in different regions separate", () => {
|
||||
const grouped = groupInventory(
|
||||
[make(), make({ region: "华南", brand: "万风" })],
|
||||
"type",
|
||||
);
|
||||
assert.equal(grouped.length, 1);
|
||||
assert.equal(grouped[0].cities.length, 2);
|
||||
assert.notEqual(grouped[0].cities[0].key, grouped[0].cities[1].key);
|
||||
});
|
||||
test("sorting reverses quantities within each city and preserves the source array", () => {
|
||||
const rows = [make(), make({ quantity: 5, model: "另一型号" })];
|
||||
assert.deepEqual(
|
||||
groupInventory(rows, "region")[0].cities[0].rows.map((row) => row.quantity),
|
||||
[5, 2],
|
||||
);
|
||||
assert.deepEqual(
|
||||
groupInventory(rows, "region", true)[0].cities[0].rows.map(
|
||||
(row) => row.quantity,
|
||||
),
|
||||
[2, 5],
|
||||
);
|
||||
assert.equal(rows[0].quantity, 2);
|
||||
assert.deepEqual(groupInventory([], "region"), []);
|
||||
});
|
||||
test("drill-down keeps brand, model, city, type, category and company; does not mistake model for contract batch", () => {
|
||||
const request = buildVehicleModalRequest(
|
||||
inventorySelection(make(), "Pending"),
|
||||
"公司甲",
|
||||
);
|
||||
assert.equal(request.kind, "vehicles");
|
||||
if (request.kind !== "vehicles") return;
|
||||
assert.deepEqual(request.params, {
|
||||
brand: "明威",
|
||||
model: "挂车",
|
||||
location: "嘉兴市",
|
||||
source: "asset",
|
||||
category: "Pending",
|
||||
vehicleType: "挂车",
|
||||
subject: "公司甲",
|
||||
});
|
||||
});
|
||||
|
||||
test("matrix merges trailers into other while preserving all five column totals", () => {
|
||||
const rows = [
|
||||
make(),
|
||||
make({ type: "35T", quantity: 3 }),
|
||||
make({ type: "4.5T普货", quantity: 4 }),
|
||||
make({ type: "4.5T冷链", quantity: 5 }),
|
||||
make({ type: "18T", quantity: 6 }),
|
||||
make({ type: "49T", quantity: 7 }),
|
||||
];
|
||||
assert.deepEqual(
|
||||
INVENTORY_TYPES.map(
|
||||
(type) =>
|
||||
inventoryTotals(
|
||||
rows.filter((row) => inventoryColumn(row.type) === type),
|
||||
).quantity,
|
||||
),
|
||||
[4, 5, 6, 7, 5],
|
||||
);
|
||||
assert.equal(inventoryTotals(rows).quantity, 27);
|
||||
});
|
||||
test("composition uses independent operation status; abnormal and pending remain subsets", () => {
|
||||
const total = inventoryTotals([
|
||||
make({
|
||||
quantity: 5,
|
||||
available: 3,
|
||||
awaiting: 2,
|
||||
abnormal: 2,
|
||||
pending: 1,
|
||||
abnormalTypes: { 维修: 2 },
|
||||
}),
|
||||
make({
|
||||
quantity: 2,
|
||||
available: 0,
|
||||
awaiting: 2,
|
||||
abnormal: 1,
|
||||
pending: 0,
|
||||
abnormalTypes: { 保养: 1 },
|
||||
}),
|
||||
]);
|
||||
assert.equal(total.available + total.awaiting, total.quantity);
|
||||
assert.equal(total.quantity, 7);
|
||||
assert.equal(total.abnormal, 3);
|
||||
assert.deepEqual(total.abnormalTypes, { 维修: 2, 保养: 1 });
|
||||
assert.equal(inventoryOperation("在库"), "unclassified");
|
||||
});
|
||||
test("model tags separate operator and color while retaining a model-first name", () => {
|
||||
assert.deepEqual(inventoryModel("现代4.5T普货(恒运)", "现代"), {
|
||||
name: "4.5T普货",
|
||||
operator: "恒运",
|
||||
});
|
||||
assert.deepEqual(inventoryModel("现代18T双飞翼(羚牛)", "帕力安牌"), {
|
||||
name: "18T双飞翼",
|
||||
operator: "羚牛",
|
||||
});
|
||||
assert.deepEqual(inventoryModel("49T宇通", "宇通"), {
|
||||
name: "49T",
|
||||
operator: "",
|
||||
});
|
||||
assert.deepEqual(inventoryModel("飞驰18T(红车)", "飞驰"), {
|
||||
name: "18T",
|
||||
operator: "",
|
||||
});
|
||||
assert.deepEqual(inventoryModel("49T飞驰(浙氢-红)", "飞驰"), {
|
||||
name: "49T",
|
||||
operator: "浙氢",
|
||||
});
|
||||
});
|
||||
test("drill-down intersects exact geographic/model/brand keys with color and operational status", () => {
|
||||
const row = make();
|
||||
const selection = scopedInventorySelection(
|
||||
{ rows: [row], label: "华东 / 嘉兴市 / 其他" },
|
||||
{ inventoryColor: "白", inventoryOperation: "available" },
|
||||
);
|
||||
const vehicle = {
|
||||
inventoryKey: inventoryKey(row),
|
||||
color: "白",
|
||||
operationStatus: "3",
|
||||
} as VehicleListItem;
|
||||
assert.equal(matchesInventorySelection(vehicle, selection), true);
|
||||
assert.equal(
|
||||
matchesInventorySelection({ ...vehicle, color: "红" }, selection),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
matchesInventorySelection({ ...vehicle, operationStatus: "4" }, selection),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
matchesInventorySelection(
|
||||
{ ...vehicle, inventoryKey: inventoryKey({ ...row, region: "华南" }) },
|
||||
selection,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
matchesInventorySelection(vehicle, { ...selection, inventoryKeys: [] }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("known color aliases combine without merging distinct multicolor values", () => {
|
||||
assert.equal(inventoryColor("红"), inventoryColor("红色"));
|
||||
assert.equal(inventoryColor("白车"), "白色");
|
||||
assert.equal(inventoryColor("蓝白绿"), "蓝白绿");
|
||||
assert.equal(inventoryColor(null), "未标注颜色");
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
emptyInventoryCounts,
|
||||
inventoryKey,
|
||||
inventoryOperation,
|
||||
inventoryColor,
|
||||
} from "../../../shared/assets/inventory";
|
||||
import type { VehicleListItem } from "../types";
|
||||
import type { RegionalInventoryStats } from "../types";
|
||||
import type { VehicleModalCategory, VehicleModalSelection } from "../model";
|
||||
|
||||
export type InventoryGrouping = "region" | "type";
|
||||
export function groupInventory(
|
||||
rows: RegionalInventoryStats[],
|
||||
grouping: InventoryGrouping,
|
||||
ascending = false,
|
||||
) {
|
||||
const groups = new Map<string, RegionalInventoryStats[]>();
|
||||
for (const row of rows) {
|
||||
const key = row[grouping] || "未归属";
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(row);
|
||||
}
|
||||
return [...groups]
|
||||
.map(([name, items]) => {
|
||||
const cities = new Map<string, RegionalInventoryStats[]>();
|
||||
for (const row of items) {
|
||||
// A city may be missing or share a name with a city in another region.
|
||||
const key = JSON.stringify([row.region, row.city]);
|
||||
if (!cities.has(key)) cities.set(key, []);
|
||||
cities.get(key)!.push(row);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
total: items.reduce((sum, row) => sum + row.quantity, 0),
|
||||
cities: [...cities]
|
||||
.map(([key, records]) => ({
|
||||
key,
|
||||
name: records[0].city || "未归属城市",
|
||||
region: records[0].region || "未归属",
|
||||
total: records.reduce((sum, row) => sum + row.quantity, 0),
|
||||
rows: [...records].sort(
|
||||
(a, b) =>
|
||||
(ascending ? 1 : -1) * (a.quantity - b.quantity) ||
|
||||
a.model.localeCompare(b.model, "zh-CN"),
|
||||
),
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.total - a.total || a.name.localeCompare(b.name, "zh-CN"),
|
||||
),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name, "zh-CN"));
|
||||
}
|
||||
export function inventorySelection(
|
||||
row: RegionalInventoryStats,
|
||||
category: VehicleModalCategory = "Inventory",
|
||||
): VehicleModalSelection {
|
||||
return {
|
||||
batch: "All",
|
||||
source: "asset",
|
||||
category,
|
||||
brand: row.brand,
|
||||
model: row.model,
|
||||
location: row.city,
|
||||
type: row.type,
|
||||
};
|
||||
}
|
||||
|
||||
export const INVENTORY_TYPES = [
|
||||
"4.5T普货",
|
||||
"4.5T冷链",
|
||||
"18T",
|
||||
"49T",
|
||||
"其他",
|
||||
] as const;
|
||||
export function inventoryColumn(type: string) {
|
||||
return INVENTORY_TYPES.includes(type as (typeof INVENTORY_TYPES)[number])
|
||||
? type
|
||||
: "其他";
|
||||
}
|
||||
export function inventoryTotals(rows: RegionalInventoryStats[]) {
|
||||
const result = emptyInventoryCounts();
|
||||
for (const row of rows) {
|
||||
for (const key of [
|
||||
"quantity",
|
||||
"available",
|
||||
"awaiting",
|
||||
"abnormal",
|
||||
"pending",
|
||||
] as const)
|
||||
result[key] += row[key] || 0;
|
||||
result.unclassified +=
|
||||
row.unclassified ??
|
||||
Math.max(0, row.quantity - (row.available || 0) - (row.awaiting || 0));
|
||||
for (const [type, count] of Object.entries(row.abnormalTypes || {}))
|
||||
result.abnormalTypes[type] = (result.abnormalTypes[type] || 0) + count;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/** Display labels never replace the original model used for exact drill-down. */
|
||||
export function inventoryModel(model: string, brand: string) {
|
||||
const parentheses = [...model.matchAll(/[((]([^))]+)[))]/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
const colorOnly = /^(?:红|白|蓝|绿|灰|黑|黄|银|蓝白绿)(?:色|车)?$/;
|
||||
const operator = parentheses
|
||||
.filter((value) => !colorOnly.test(value))
|
||||
.map((value) => value.replace(/[--](?:红|白|蓝白绿)(?:色|车)?$/, ""))
|
||||
.join(" · ");
|
||||
let name = model.replace(/[((][^))]*[))]/g, "").trim();
|
||||
for (const label of [
|
||||
brand,
|
||||
"现代",
|
||||
"帕力安牌",
|
||||
"宇通",
|
||||
"飞驰",
|
||||
"苏龙",
|
||||
"跃进",
|
||||
"楚风",
|
||||
]) {
|
||||
if (label && name.startsWith(label))
|
||||
name = name.slice(label.length).replace(/^-/, "");
|
||||
if (label && name.endsWith(label)) name = name.slice(0, -label.length);
|
||||
}
|
||||
return { name: name || model, operator };
|
||||
}
|
||||
export interface InventoryScope {
|
||||
rows: RegionalInventoryStats[];
|
||||
label: string;
|
||||
initialCategory?: "Abnormal" | "Pending";
|
||||
}
|
||||
export function scopedInventorySelection(
|
||||
scope: InventoryScope,
|
||||
extra: Partial<VehicleModalSelection> = {},
|
||||
): VehicleModalSelection {
|
||||
return {
|
||||
batch: "All",
|
||||
model: "All",
|
||||
location: "All",
|
||||
source: "asset",
|
||||
category: "Inventory",
|
||||
inventoryKeys: scope.rows.map(inventoryKey),
|
||||
scopeLabel: scope.label,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
export function matchesInventorySelection(
|
||||
vehicle: VehicleListItem,
|
||||
selection: VehicleModalSelection,
|
||||
) {
|
||||
return (
|
||||
(!selection.inventoryKeys ||
|
||||
selection.inventoryKeys.includes(vehicle.inventoryKey || "")) &&
|
||||
(!selection.inventoryOperation ||
|
||||
inventoryOperation(vehicle.operationStatus) ===
|
||||
selection.inventoryOperation) &&
|
||||
(!selection.inventoryColor ||
|
||||
inventoryColor(vehicle.color) ===
|
||||
inventoryColor(selection.inventoryColor)) &&
|
||||
(!selection.abnormalType ||
|
||||
(vehicle.abnormalMove?.type || "未提供") === selection.abnormalType)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowUpRight, SlidersHorizontal } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Field, FieldLabel } from "@/components/ui/field";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { useDemoMode } from "../../../components/Blur";
|
||||
import type { VehicleModalSelection } from "../model";
|
||||
export type OpenVehicles = (selection: VehicleModalSelection) => void;
|
||||
export const baseSelection: VehicleModalSelection = {
|
||||
batch: "All",
|
||||
model: "All",
|
||||
location: "All",
|
||||
source: "asset",
|
||||
};
|
||||
export const unique = (values: (string | null | undefined)[]) =>
|
||||
[...new Set(values.filter((v): v is string => !!v))].sort();
|
||||
|
||||
/** 区块级 Tab 统一样式:模块级与卡片内嵌 Tab 共用,避免多套视觉。 */
|
||||
export const sectionTabsListClass =
|
||||
"w-full min-h-11 bg-[var(--muted)] md:w-fit";
|
||||
export const sectionTabsTriggerClass =
|
||||
"min-h-11 px-4 data-[state=active]:text-[var(--blue)] dark:data-[state=active]:text-[var(--cyan)]";
|
||||
|
||||
/** 数字滚动动画:目标值变化时从当前值缓动到新值;尊重 reduced-motion。 */
|
||||
export function useCountUp(target: number, duration = 800) {
|
||||
const [value, setValue] = useState(0);
|
||||
const valueRef = useRef(0);
|
||||
const rafRef = useRef(0);
|
||||
useEffect(() => {
|
||||
const from = valueRef.current;
|
||||
if (from === target) return undefined;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
valueRef.current = target;
|
||||
setValue(target);
|
||||
return undefined;
|
||||
}
|
||||
const start = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min(1, (now - start) / duration);
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
const current = Math.round(from + (target - from) * eased);
|
||||
valueRef.current = current;
|
||||
setValue(current);
|
||||
if (t < 1) rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [target, duration]);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 占比条:用于 KPI 卡片内的构成占比(如 租赁/自营)。 */
|
||||
export function ProportionBar({
|
||||
segments,
|
||||
label,
|
||||
}: {
|
||||
segments: { value: number; className: string; title: string }[];
|
||||
label: string;
|
||||
}) {
|
||||
const total = segments.reduce((n, s) => n + s.value, 0);
|
||||
return (
|
||||
<div
|
||||
className="flex h-1.5 w-full overflow-hidden rounded-full bg-[var(--fill-3)]"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
>
|
||||
{total > 0 &&
|
||||
segments
|
||||
.filter((s) => s.value > 0)
|
||||
.map((s) => (
|
||||
<div
|
||||
key={s.title}
|
||||
title={`${s.title} ${s.value.toLocaleString()}`}
|
||||
className={s.className}
|
||||
style={{ width: `${(s.value / total) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function PrivateText({ children }: { children: ReactNode }) {
|
||||
return <>{useDemoMode() ? "••••••" : children || "—"}</>;
|
||||
}
|
||||
export function Count({
|
||||
value,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
value: number;
|
||||
onClick: () => void;
|
||||
label?: string;
|
||||
}) {
|
||||
if (!value)
|
||||
return (
|
||||
<span className="asset-zero" title="暂无明细">
|
||||
0
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="asset-count"
|
||||
onClick={onClick}
|
||||
aria-label={
|
||||
label ? `查看${label}:${value} 条明细` : `查看 ${value} 条明细`
|
||||
}
|
||||
>
|
||||
{value.toLocaleString()}
|
||||
<ArrowUpRight aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
export function Picker({
|
||||
label,
|
||||
value = "",
|
||||
values,
|
||||
change,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
values: string[];
|
||||
change: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<Select
|
||||
value={value || "__all__"}
|
||||
onValueChange={(v) => change(v === "__all__" ? "" : v)}
|
||||
>
|
||||
<SelectTrigger className="min-h-11 w-full" aria-label={label}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="__all__">
|
||||
全部{label === "所属公司" ? "公司" : label}
|
||||
</SelectItem>
|
||||
{values.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
export function FilterFields({
|
||||
fields,
|
||||
onReset,
|
||||
}: {
|
||||
fields: Parameters<typeof Picker>[0][];
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const active = fields.filter((field) => field.value);
|
||||
return (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="rounded-lg border border-[var(--border)] bg-[var(--bg-secondary)] px-3"
|
||||
>
|
||||
<AccordionItem value="filters" className="border-0">
|
||||
<AccordionTrigger className="min-h-11 py-3 hover:no-underline">
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-2 text-left">
|
||||
<SlidersHorizontal className="size-4 shrink-0 text-blue-600 dark:text-blue-300" />
|
||||
<span className="font-semibold">筛选条件</span>
|
||||
{active.length ? (
|
||||
active.map((field) => (
|
||||
<Badge
|
||||
key={field.label}
|
||||
variant="secondary"
|
||||
className="max-w-52 truncate bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-200"
|
||||
>
|
||||
{field.label}:{field.value}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
未设条件
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-3">
|
||||
<div className="grid grid-cols-2 items-end gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
{fields.map((field) => (
|
||||
<Picker key={field.label} {...field} />
|
||||
))}
|
||||
<Button
|
||||
className="min-h-11"
|
||||
variant="outline"
|
||||
onClick={onReset}
|
||||
disabled={!active.length}
|
||||
>
|
||||
重置筛选
|
||||
</Button>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
export function Failure({
|
||||
message,
|
||||
retry,
|
||||
}: {
|
||||
message: string;
|
||||
retry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>数据加载失败</AlertTitle>
|
||||
<AlertDescription>
|
||||
{message}
|
||||
{retry && (
|
||||
<Button variant="outline" onClick={retry}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
export function useMobile() {
|
||||
const [mobile, setMobile] = useState(
|
||||
() => window.matchMedia("(max-width: 767px)").matches,
|
||||
);
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(max-width: 767px)");
|
||||
const update = () => setMobile(query.matches);
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
return mobile;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const TAB_IDS = ["overview", "department", "region", "customer"] as const;
|
||||
const SECTION_IDS = ["inventory", "models", "flow"] as const;
|
||||
|
||||
export type AssetTab = (typeof TAB_IDS)[number];
|
||||
export type AssetSection = (typeof SECTION_IDS)[number];
|
||||
|
||||
interface AssetHashState {
|
||||
tab: AssetTab;
|
||||
section: AssetSection;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: AssetHashState = { tab: "overview", section: "inventory" };
|
||||
|
||||
const isTab = (value: string | undefined): value is AssetTab =>
|
||||
Boolean(value) && (TAB_IDS as readonly string[]).includes(value as string);
|
||||
const isSection = (value: string | undefined): value is AssetSection =>
|
||||
Boolean(value) && (SECTION_IDS as readonly string[]).includes(value as string);
|
||||
|
||||
function parseHash(): AssetHashState {
|
||||
const [first, second, third] = window.location.hash.slice(1).split("/");
|
||||
if (first !== "assets") return DEFAULT_STATE;
|
||||
return {
|
||||
tab: isTab(second) ? second : "overview",
|
||||
section: isSection(third) ? third : "inventory",
|
||||
};
|
||||
}
|
||||
|
||||
function writeHash(tab: AssetTab, section: AssetSection) {
|
||||
const parts = ["assets", tab];
|
||||
// 默认子页签不写入,保持 `#assets/<tab>` 简洁。
|
||||
if (tab === "overview" && section !== "inventory") parts.push(section);
|
||||
const hash = `#${parts.join("/")}`;
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}${hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产模块页签状态 ↔ URL hash 二级/三级段(`#assets/<tab>[/<section>]`)。
|
||||
* 默认值不写入 hash,刷新可恢复,链接可分享;与 Shell 的一级模块段兼容。
|
||||
*/
|
||||
export function useAssetHash() {
|
||||
const [state, setState] = useState<AssetHashState>(parseHash);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setState(parseHash());
|
||||
window.addEventListener("hashchange", onChange);
|
||||
return () => window.removeEventListener("hashchange", onChange);
|
||||
}, []);
|
||||
|
||||
const update = (next: Partial<AssetHashState>) => {
|
||||
const merged = { ...state, ...next };
|
||||
writeHash(merged.tab, merged.section);
|
||||
setState(merged);
|
||||
};
|
||||
|
||||
return [state, update] as const;
|
||||
}
|
||||
Reference in New Issue
Block a user