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