import { useMemo, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchDepartments } from "../api/departments"; import { mapApiErrorMessage } from "../api/errorMessages"; import { fetchReimbursementApprovalBottleneck, fetchReimbursementStatsSummary, fetchReimbursementStatsTrend, fetchReimbursementTopExpenseTypes, type ReimbursementStatsQuery, } from "../api/reimbursements"; import { isApiError } from "../api/types"; import { PageHeader } from "../components/common/PageHeader"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { Button } from "../components/ui/button"; function currentDateString() { return new Date().toISOString().slice(0, 10); } function defaultDateFromString() { const now = new Date(); now.setDate(1); now.setMonth(now.getMonth() - 2); return now.toISOString().slice(0, 10); } function resolveErrorMessage(error: unknown) { if (isApiError(error)) { return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; } if (error instanceof Error) { return error.message; } return "加载失败,请稍后重试。"; } function formatCurrency(value: string | number | null | undefined) { const numeric = Number(value ?? 0); return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format( Number.isFinite(numeric) ? numeric : 0, ); } function formatAvgDays(value: number | null) { if (value == null) { return "-"; } const normalized = Number(value); if (!Number.isFinite(normalized)) { return "-"; } return `${normalized.toFixed(1)} 天`; } function formatPeriod(value: string | null) { if (!value) { return "-"; } return value.length === 7 ? value : value.replace("T", " ").slice(0, 10); } function normalizeBarWidth(current: number, max: number) { if (max <= 0 || current <= 0) { return "0%"; } return `${Math.max(8, Math.min(100, (current / max) * 100))}%`; } export function ReimbursementDashboardPage() { const queryClient = useQueryClient(); const [dateFrom, setDateFrom] = useState(defaultDateFromString()); const [dateTo, setDateTo] = useState(currentDateString()); const [departmentId, setDepartmentId] = useState(""); const filters = useMemo( () => ({ dateFrom: dateFrom || undefined, dateTo: dateTo || undefined, departmentId: departmentId || undefined, }), [dateFrom, dateTo, departmentId], ); const departmentsQuery = useQuery({ queryKey: ["departments", "reimbursements-dashboard"], queryFn: () => fetchDepartments({ includeArchived: false }), }); const summaryQuery = useQuery({ queryKey: ["reimbursements", "stats", "summary", filters], queryFn: () => fetchReimbursementStatsSummary(filters), }); const trendQuery = useQuery({ queryKey: ["reimbursements", "stats", "trend", filters], queryFn: () => fetchReimbursementStatsTrend({ ...filters, granularity: "month" }), }); const topExpenseTypesQuery = useQuery({ queryKey: ["reimbursements", "stats", "top-expense-types", filters], queryFn: () => fetchReimbursementTopExpenseTypes({ ...filters, limit: 10 }), }); const bottleneckQuery = useQuery({ queryKey: ["reimbursements", "stats", "approval-bottleneck", filters], queryFn: () => fetchReimbursementApprovalBottleneck(filters), }); const loading = summaryQuery.isLoading || trendQuery.isLoading || topExpenseTypesQuery.isLoading || bottleneckQuery.isLoading; const firstError = summaryQuery.error ?? trendQuery.error ?? topExpenseTypesQuery.error ?? bottleneckQuery.error ?? departmentsQuery.error; const summary = summaryQuery.data; const trendItems = trendQuery.data?.items ?? []; const topExpenseTypes = topExpenseTypesQuery.data?.items ?? []; const bottlenecks = bottleneckQuery.data?.items ?? []; const maxTrendCount = Math.max(1, ...trendItems.map((item) => Number(item.count ?? 0))); const maxTrendAmount = Math.max(1, ...trendItems.map((item) => Number(item.amount ?? 0))); const maxExpenseTypeAmount = Math.max(1, ...topExpenseTypes.map((item) => Number(item.totalAmount ?? 0))); async function handleRefresh() { await queryClient.invalidateQueries({ queryKey: ["reimbursements", "stats"] }); } return (
void handleRefresh()}> 刷新 } /> {firstError ? (

{resolveErrorMessage(firstError)}

) : null}
setDateFrom(event.target.value)} />
setDateTo(event.target.value)} />
默认窗口:近 3 个自然月
报销总数
{loading ? "..." : summary?.totalCount ?? 0}
报销总金额
{loading ? "..." : formatCurrency(summary?.totalAmount)}
平均处理天数
{loading ? "..." : formatAvgDays(summary?.avgProcessingDays ?? null)}
待审批数
{loading ? "..." : summary?.byStatus.reviewing.count ?? 0}
{trendItems.length ? (
{trendItems.map((item) => (
{formatPeriod(item.period)}
已支付 {item.paidCount} 单
数量 {item.count} 单
金额 {formatCurrency(item.amount)}
))}
) : (
当前筛选下暂无趋势数据。
)}
{topExpenseTypes.length ? (
{topExpenseTypes.map((item, index) => (
{index + 1}. {item.expenseTypeName}
{item.count} 条报销明细
{formatCurrency(item.totalAmount)}
))}
) : (
当前筛选下暂无费用类型排行。
)} {bottlenecks.length ? (
{bottlenecks.map((item) => { const isStale = item.oldestPendingDays > 3; return (
{item.assigneeName}
待处理 {item.pendingCount} 单
最长等待 {item.oldestPendingDays} 天
); })}
) : (
当前筛选下暂无审批瓶颈。
)}
); }