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({ items: [], summary: { count: 0, amountCents: 0 } }); const [reimbursed, setReimbursed] = useState({ items: [], summary: { count: 0, amountCents: 0 } }); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [loadedMonth, setLoadedMonth] = useState(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(`/api/expenses?month=${month}&status=unreimbursed&query=&missingInvoice=false`), api(`/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(); 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) => {money(row.amountCents)} }, { colKey: "note", title: "备注", cell: ({ row }: any) => row.note || 无备注, ellipsis: true }, { colKey: "status", title: "状态", cell: ({ row }: any) => {row.status === "reimbursed" ? "已报销" : "未报销"} }, ]; return

仪表盘

快速查看本月采购报销进度和待处理账目。

{error && hasCurrentSnapshot && } message={`刷新失败:${error}。当前展示的是本月最近一次成功加载的数据。`} />} {loading ?
: !hasCurrentSnapshot ?
} message={error || "暂时无法读取仪表盘数据"} />
: <>
}> {allItems.length ?
:
本月暂无支付数据新增账目后,这里会显示每日支付趋势。
}
}>
{reimbursedPercent}%已报销
未报销 {unreimbursed.summary.count} 笔已报销 {reimbursed.summary.count} 笔
} actions={}> {recentItems.length ?
: } } ; } function DashboardCardTitle({ title, subtitle }: { title: string; subtitle: string }) { return
{title}{subtitle}
; }