文件
hyb-oa/client/src/pages/DashboardPage.tsx
T

324 行
11 KiB
TypeScript

import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { exportDashboardReimbursements } from "../api/exports";
import {
fetchDashboardApprovalEfficiency,
fetchDashboardOverview,
fetchDashboardTopCategories,
fetchDashboardTrends,
type DashboardQuery,
} from "../api/dashboard";
import { fetchDepartments } from "../api/departments";
import { toast } from "sonner";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { downloadBlob } from "../lib/download";
function currentDateString() {
return new Date().toISOString().slice(0, 10);
}
function monthStartString() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
}
function toCurrency(value: string | number | null | undefined) {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) {
return "0.00";
}
return numeric.toFixed(2);
}
function resolveErrorMessage(error: unknown) {
return resolveUserErrorMessage(error, "加载失败,请稍后重试。");
}
export function DashboardPage() {
const { permissions, me } = useAuth();
const canViewDashboard = hasPermission(permissions, "report.dashboard.view");
const [dateFrom, setDateFrom] = useState(monthStartString());
const [dateTo, setDateTo] = useState(currentDateString());
const [departmentId, setDepartmentId] = useState("");
const queryParams = useMemo<DashboardQuery>(
() => ({
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
departmentId: departmentId || undefined,
}),
[dateFrom, dateTo, departmentId],
);
const departmentsQuery = useQuery({
enabled: canViewDashboard,
queryKey: ["departments", "dashboard-filter"],
queryFn: () => fetchDepartments(),
});
const overviewQuery = useQuery({
enabled: canViewDashboard,
queryKey: ["dashboard", "overview", queryParams],
queryFn: () => fetchDashboardOverview(queryParams),
});
const trendsQuery = useQuery({
enabled: canViewDashboard,
queryKey: ["dashboard", "trends", queryParams],
queryFn: () => fetchDashboardTrends(queryParams),
});
const topCategoriesQuery = useQuery({
enabled: canViewDashboard,
queryKey: ["dashboard", "top-categories", queryParams],
queryFn: () => fetchDashboardTopCategories({ ...queryParams, limit: 8 }),
});
const approvalEfficiencyQuery = useQuery({
enabled: canViewDashboard,
queryKey: ["dashboard", "approval-efficiency", queryParams],
queryFn: () => fetchDashboardApprovalEfficiency(queryParams),
});
const exportMutation = useMutation({
mutationFn: (format: "csv" | "xlsx") =>
exportDashboardReimbursements({
format,
dateFrom: queryParams.dateFrom,
dateTo: queryParams.dateTo,
departmentId: queryParams.departmentId,
}),
onSuccess: (response, format) => {
downloadBlob(response.blob, response.filename ?? `dashboard-reimbursements.${format}`);
toast.success("导出完成");
},
onError: (err) => {
toast.error(resolveUserErrorMessage(err, "导出失败"));
},
});
if (!canViewDashboard) {
return (
<div className="space-y-4">
<PageHeader title="仪表盘" subtitle="你当前没有看板权限,请联系管理员开通。" />
<div className="card-std space-y-2 p-6 text-sm">
<p className="text-zinc-700">当前用户:{me?.name ?? "-"}</p>
<p className="text-zinc-500">所需权限:report.dashboard.view</p>
</div>
</div>
);
}
const loading =
overviewQuery.isLoading ||
trendsQuery.isLoading ||
topCategoriesQuery.isLoading ||
approvalEfficiencyQuery.isLoading;
const firstError =
overviewQuery.error ??
trendsQuery.error ??
topCategoriesQuery.error ??
approvalEfficiencyQuery.error ??
departmentsQuery.error ??
exportMutation.error;
const trendPoints = trendsQuery.data?.points ?? [];
const maxTrendAmount = Math.max(
1,
...trendPoints.map((item) => Number(item.amount ?? 0)).filter((value) => Number.isFinite(value)),
);
const topCategories = topCategoriesQuery.data?.items ?? [];
const maxCategoryAmount = Math.max(
1,
...topCategories.map((item) => Number(item.amount ?? 0)).filter((value) => Number.isFinite(value)),
);
return (
<div className="space-y-6">
<PageHeader
title="仪表盘"
subtitle="费用概览、趋势、分类与审批效率。支持筛选与导出。"
/>
{firstError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(firstError)}
</p>
) : null}
<div className="card-std space-y-4 p-6">
<div className="grid gap-3 md:grid-cols-4">
<div className="space-y-1">
<label className="text-xs text-zinc-600" htmlFor="dashboard-date-from">
开始日期
</label>
<input
id="dashboard-date-from"
className="input-std"
type="date"
value={dateFrom}
onChange={(event) => setDateFrom(event.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-600" htmlFor="dashboard-date-to">
结束日期
</label>
<input
id="dashboard-date-to"
className="input-std"
type="date"
value={dateTo}
onChange={(event) => setDateTo(event.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-600" htmlFor="dashboard-department-id">
部门
</label>
<select
id="dashboard-department-id"
className="select-std"
value={departmentId}
onChange={(event) => setDepartmentId(event.target.value)}
>
<option value="">全部部门</option>
{(departmentsQuery.data?.items ?? []).map((department) => (
<option key={department.id} value={department.id}>
{department.name}
</option>
))}
</select>
</div>
<div className="flex items-end gap-2">
<Button
variant="secondary"
type="button"
disabled={exportMutation.isPending}
onClick={() => exportMutation.mutate("csv")}
>
导出 CSV
</Button>
<Button
variant="secondary"
type="button"
disabled={exportMutation.isPending}
onClick={() => exportMutation.mutate("xlsx")}
>
导出 Excel
</Button>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
<div className="card-std p-4 space-y-1">
<p className="text-xs text-zinc-500">报销单总数</p>
<p className="mt-1 text-2xl font-semibold text-zinc-900">
{loading ? "..." : overviewQuery.data?.totalCount ?? 0}
</p>
</div>
<div className="card-std p-4 space-y-1">
<p className="text-xs text-zinc-500">报销总金额</p>
<p className="mt-1 text-2xl font-semibold text-zinc-900">
{loading ? "..." : toCurrency(overviewQuery.data?.totalAmount)}
</p>
</div>
<div className="card-std p-4 space-y-1">
<p className="text-xs text-zinc-500">已付款金额</p>
<p className="mt-1 text-2xl font-semibold text-zinc-900">
{loading ? "..." : toCurrency(overviewQuery.data?.paidAmount)}
</p>
</div>
<div className="card-std p-4 space-y-1">
<p className="text-xs text-zinc-500">审批中数量</p>
<p className="mt-1 text-2xl font-semibold text-zinc-900">
{loading ? "..." : overviewQuery.data?.reviewingCount ?? 0}
</p>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="card-std p-6 space-y-4">
<h3 className="text-base font-semibold text-zinc-900">费用趋势</h3>
{trendPoints.length ? (
<div className="space-y-2">
{trendPoints.map((item) => {
const amount = Number(item.amount ?? 0);
const width = `${Math.max(5, Math.round((amount / maxTrendAmount) * 100))}%`;
return (
<div key={item.date} className="space-y-1">
<div className="flex items-center justify-between text-xs text-zinc-600">
<span>{item.date}</span>
<span>
{toCurrency(item.amount)} / {item.count} 笔
</span>
</div>
<div className="h-2 rounded-full bg-zinc-100">
<div className="h-2 rounded-full bg-primary" style={{ width }} />
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-zinc-500">暂无趋势数据</p>
)}
</div>
<div className="card-std p-6 space-y-4">
<h3 className="text-base font-semibold text-zinc-900">费用分类 TopN</h3>
{topCategories.length ? (
<div className="space-y-2">
{topCategories.map((item) => {
const amount = Number(item.amount ?? 0);
const width = `${Math.max(5, Math.round((amount / maxCategoryAmount) * 100))}%`;
return (
<div key={item.code} className="space-y-1">
<div className="flex items-center justify-between text-xs text-zinc-600">
<span>
{item.name} ({item.code})
</span>
<span>
{toCurrency(item.amount)} / {item.count} 笔
</span>
</div>
<div className="h-2 rounded-full bg-zinc-100">
<div className="h-2 rounded-full bg-emerald-500" style={{ width }} />
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-zinc-500">暂无分类统计</p>
)}
</div>
</div>
<div className="card-std p-6 space-y-2 text-sm">
<h3 className="text-base font-semibold text-zinc-900">审批效率</h3>
<p className="text-zinc-700">
已统计通过单据:
<span className="ml-1 font-medium text-zinc-900">
{approvalEfficiencyQuery.data?.approvedCount ?? 0}
</span>
</p>
<p className="text-zinc-700">
平均审批耗时(小时):
<span className="ml-1 font-medium text-zinc-900">
{approvalEfficiencyQuery.data?.averageHours?.toFixed(2) ?? "0.00"}
</span>
</p>
</div>
</div>
);
}