This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertCircle, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react";
|
||||
import { Alert, Button, Card, DatePicker, Empty, Loading, Space, Statistic, Table, Tag, Tooltip } from "tdesign-react";
|
||||
import ReactEChartsCore from "echarts-for-react/lib/core";
|
||||
import * as echarts from "echarts/core";
|
||||
import { LineChart } from "echarts/charts";
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { api } from "../../services/api";
|
||||
import { dateText, money, monthNow } from "../expenses/date";
|
||||
import type { Expense } from "../expenses/types";
|
||||
import type { RouteId } from "../../router";
|
||||
|
||||
echarts.use([LineChart, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer]);
|
||||
|
||||
type Props = { timezone?: string; onNavigate?: (id: RouteId, search?: string) => void };
|
||||
type ExpenseResult = { items: Expense[]; summary: { count: number; amountCents: number } };
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.matchMedia === "function"
|
||||
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
: false;
|
||||
}
|
||||
|
||||
export default function DashboardPage({ timezone = "Asia/Shanghai", onNavigate }: Props) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const rawMonthParam = searchParams.get("month");
|
||||
const defaultMonth = monthNow(timezone);
|
||||
const month = rawMonthParam && /^\d{4}-(0[1-9]|1[0-2])$/.test(rawMonthParam) ? rawMonthParam : defaultMonth;
|
||||
const [unreimbursed, setUnreimbursed] = useState<ExpenseResult>({ items: [], summary: { count: 0, amountCents: 0 } });
|
||||
const [reimbursed, setReimbursed] = useState<ExpenseResult>({ items: [], summary: { count: 0, amountCents: 0 } });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [loadedMonth, setLoadedMonth] = useState<string | null>(null);
|
||||
const requestSequence = useRef(0);
|
||||
const [reducedMotion, setReducedMotion] = useState(prefersReducedMotion);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const sync = () => setReducedMotion(media.matches);
|
||||
sync();
|
||||
if (typeof media.addEventListener === "function") {
|
||||
media.addEventListener("change", sync);
|
||||
return () => media.removeEventListener("change", sync);
|
||||
}
|
||||
media.addListener?.(sync);
|
||||
return () => media.removeListener?.(sync);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rawMonthParam || rawMonthParam === month) return;
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("month", month);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [month, rawMonthParam, searchParams, setSearchParams]);
|
||||
|
||||
const setDashboardMonth = (nextMonth: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("month", nextMonth);
|
||||
setSearchParams(params);
|
||||
};
|
||||
const load = async () => {
|
||||
const sequence = ++requestSequence.current;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [pending, done] = await Promise.all([
|
||||
api<ExpenseResult>(`/api/expenses?month=${month}&status=unreimbursed&query=&missingInvoice=false`),
|
||||
api<ExpenseResult>(`/api/expenses?month=${month}&status=reimbursed&query=&missingInvoice=false`),
|
||||
]);
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setUnreimbursed(pending);
|
||||
setReimbursed(done);
|
||||
setLoadedMonth(month);
|
||||
} catch (caught) {
|
||||
if (sequence === requestSequence.current) setError((caught as Error).message);
|
||||
} finally {
|
||||
if (sequence === requestSequence.current) setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => { void load(); }, [month]);
|
||||
|
||||
const allItems = useMemo(() => [...unreimbursed.items, ...reimbursed.items].sort((a, b) => b.paidAt - a.paidAt), [unreimbursed.items, reimbursed.items]);
|
||||
const totalCents = unreimbursed.summary.amountCents + reimbursed.summary.amountCents;
|
||||
const totalCount = unreimbursed.summary.count + reimbursed.summary.count;
|
||||
const missingInvoiceCount = allItems.filter((item) => item.invoiceCount === 0).length;
|
||||
const recentItems = allItems.slice(0, 8);
|
||||
const reimbursedPercent = totalCount ? Math.round((reimbursed.summary.count / totalCount) * 100) : 0;
|
||||
const hasCurrentSnapshot = loadedMonth === month;
|
||||
const expensesSearch = `?month=${encodeURIComponent(month)}`;
|
||||
const chartDateFormatter = useMemo(() => new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit" }), [timezone]);
|
||||
|
||||
const chartOption = useMemo(() => {
|
||||
const grouped = new Map<string, { label: string; unreimbursed: number; reimbursed: number }>();
|
||||
for (const item of allItems) {
|
||||
const parts = Object.fromEntries(chartDateFormatter.formatToParts(new Date(item.paidAt)).map((part) => [part.type, part.value]));
|
||||
const key = `${parts.year}-${parts.month}-${parts.day}`;
|
||||
const label = `${parts.month}/${parts.day}`;
|
||||
const current = grouped.get(key) ?? { label, unreimbursed: 0, reimbursed: 0 };
|
||||
current[item.status] += item.amountCents / 100;
|
||||
grouped.set(key, current);
|
||||
}
|
||||
const values = [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, value]) => value);
|
||||
return {
|
||||
animation: !reducedMotion,
|
||||
animationDuration: reducedMotion ? 0 : 380,
|
||||
color: ["#175cd3", "#2f7d5c"],
|
||||
tooltip: { trigger: "axis", valueFormatter: (value: number) => `¥${value.toFixed(2)}` },
|
||||
legend: { top: 0, right: 0, textStyle: { color: "#667085" }, data: ["未报销", "已报销"] },
|
||||
grid: { left: 48, right: 20, top: 38, bottom: 30 },
|
||||
xAxis: { type: "category", data: values.map((value) => value.label), boundaryGap: false, axisLine: { lineStyle: { color: "#dce3ec" } }, axisLabel: { color: "#667085" } },
|
||||
yAxis: { type: "value", axisLabel: { color: "#667085", formatter: (value: number) => `¥${value}` }, splitLine: { lineStyle: { color: "#edf1f5" } } },
|
||||
series: [
|
||||
{ name: "未报销", type: "line", smooth: true, symbol: "circle", symbolSize: 6, data: values.map((value) => value.unreimbursed), lineStyle: { width: 2 }, areaStyle: { color: "rgba(23,92,211,.08)" } },
|
||||
{ name: "已报销", type: "line", smooth: true, symbol: "circle", symbolSize: 6, data: values.map((value) => value.reimbursed), lineStyle: { width: 2 }, areaStyle: { color: "rgba(47,125,92,.06)" } },
|
||||
],
|
||||
};
|
||||
}, [allItems, chartDateFormatter, reducedMotion]);
|
||||
|
||||
const shiftMonth = (delta: number) => {
|
||||
const [rawYear, rawMonth] = month.split("-").map(Number);
|
||||
const year = rawYear ?? new Date().getFullYear();
|
||||
const monthNumber = rawMonth ?? 1;
|
||||
const next = new Date(year, monthNumber - 1 + delta, 1);
|
||||
setDashboardMonth(`${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}`);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ colKey: "paidAt", title: "支付时间", cell: ({ row }: any) => dateText(row.paidAt, timezone) },
|
||||
{ colKey: "amountCents", title: "金额", cell: ({ row }: any) => <strong className="tn-amount">{money(row.amountCents)}</strong> },
|
||||
{ colKey: "note", title: "备注", cell: ({ row }: any) => row.note || <span className="tn-muted">无备注</span>, ellipsis: true },
|
||||
{ colKey: "status", title: "状态", cell: ({ row }: any) => <Tag theme={row.status === "reimbursed" ? "success" : "warning"}>{row.status === "reimbursed" ? "已报销" : "未报销"}</Tag> },
|
||||
];
|
||||
|
||||
return <section className="tn-page tn-dashboard-page">
|
||||
<div className="tn-page-head">
|
||||
<div><h1 className="tn-page-title">仪表盘</h1><p className="tn-page-subtitle">快速查看本月采购报销进度和待处理账目。</p></div>
|
||||
<div className="tn-page-actions"><div className="tn-dashboard-actions"><div className="tn-dashboard-month-control"><Tooltip content="上个月"><Button variant="outline" shape="square" onClick={() => shiftMonth(-1)} aria-label="上个月" icon={<ChevronLeft size={16} />} /></Tooltip><DatePicker className="tn-dashboard-month" mode="month" format="YYYY-MM" value={month} onChange={(value: any) => { const next = String(value || "").slice(0, 7); if (/^\d{4}-\d{2}$/.test(next)) setDashboardMonth(next); }} inputProps={{ "aria-label": "仪表盘月份" } as any} /><Tooltip content="下个月"><Button variant="outline" shape="square" onClick={() => shiftMonth(1)} aria-label="下个月" icon={<ChevronRight size={16} />} /></Tooltip></div><div className="tn-dashboard-secondary-actions"><Button className="tn-dashboard-refresh" variant="outline" onClick={() => void load()} disabled={loading} icon={<RefreshCw size={15} />}>刷新</Button><Button className="tn-dashboard-view" theme="primary" onClick={() => onNavigate?.("expenses", expensesSearch)}>查看账目</Button></div></div></div>
|
||||
</div>
|
||||
{error && hasCurrentSnapshot && <Alert theme="error" icon={<AlertCircle size={16} />} message={`刷新失败:${error}。当前展示的是本月最近一次成功加载的数据。`} />}
|
||||
{loading ? <div className="tn-empty" role="status" aria-live="polite"><Loading text="加载仪表盘…" /></div> : !hasCurrentSnapshot ? <div className="tn-empty" role="alert"><Alert theme="error" icon={<AlertCircle size={16} />} message={error || "暂时无法读取仪表盘数据"} /><Button variant="outline" onClick={() => void load()} icon={<RefreshCw size={15} />}>重新加载</Button></div> : <>
|
||||
<div className="tn-dashboard-stats">
|
||||
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-total"><Statistic title="本月总额" value={totalCents / 100} prefix="¥" decimalPlaces={2} /></Card>
|
||||
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-count"><Statistic title="账目笔数" value={totalCount} suffix="笔" /></Card>
|
||||
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-pending"><Statistic title="未报销" value={unreimbursed.summary.amountCents / 100} prefix="¥" decimalPlaces={2} /></Card>
|
||||
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-missing"><Statistic title="缺发票" value={missingInvoiceCount} suffix="笔" /></Card>
|
||||
</div>
|
||||
<div className="tn-dashboard-grid">
|
||||
<Card bordered className="tn-dashboard-chart" title={<DashboardCardTitle title="支付趋势" subtitle="按支付日期汇总本月全部账目" />}>
|
||||
{allItems.length ? <div role="img" aria-label={`${month} 支付趋势:共 ${totalCount} 笔,未报销 ${unreimbursed.summary.count} 笔,已报销 ${reimbursed.summary.count} 笔。`}><ReactEChartsCore echarts={echarts} option={chartOption} style={{ height: 286, width: "100%" }} notMerge lazyUpdate /></div> : <div className="tn-dashboard-chart-empty"><span>本月暂无支付数据</span><small>新增账目后,这里会显示每日支付趋势。</small></div>}
|
||||
</Card>
|
||||
<Card bordered className="tn-dashboard-progress" title={<DashboardCardTitle title="报销进度" subtitle="本月账目状态分布" />}><div className="tn-progress-ring" style={{ background: `conic-gradient(var(--tn-blue-700) ${reimbursedPercent}%, #e7edf3 0)` }} role="img" aria-label={`已报销 ${reimbursedPercent}%`}><div><strong>{reimbursedPercent}%</strong><span>已报销</span></div></div><div className="tn-dashboard-progress-legend"><span><i className="is-blue" />未报销 <strong>{unreimbursed.summary.count} 笔</strong></span><span><i className="is-green" />已报销 <strong>{reimbursed.summary.count} 笔</strong></span></div></Card>
|
||||
</div>
|
||||
<Card bordered className="tn-dashboard-recent" title={<DashboardCardTitle title="最近账目" subtitle="按支付时间倒序排列" />} actions={<Button variant="text" onClick={() => onNavigate?.("expenses", expensesSearch)}>查看全部</Button>}>
|
||||
{recentItems.length ? <div className="tn-table-wrap tn-dashboard-table" role="region" aria-label="最近账目列表,可横向滚动查看更多列"><Table rowKey="id" data={recentItems} columns={columns as any} hover stripe /> </div> : <Empty description="本月暂无账目" />}
|
||||
</Card>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function DashboardCardTitle({ title, subtitle }: { title: string; subtitle: string }) {
|
||||
return <div className="tn-dashboard-card-title"><span>{title}</span><small>{subtitle}</small></div>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as DashboardPage } from "./DashboardPage";
|
||||
export { default } from "./DashboardPage";
|
||||
Reference in New Issue
Block a user