285 lines
9.7 KiB
TypeScript
285 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|