feat: 完成P6-P8前端能力闭环(V3,D2,D3,B3)
这个提交包含在:
+309
-58
@@ -1,77 +1,328 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { EmptyStateCard } from "../components/common/EmptyStateCard";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { exportDashboardReimbursements } from "../api/exports";
|
||||
import {
|
||||
fetchDashboardApprovalEfficiency,
|
||||
fetchDashboardOverview,
|
||||
fetchDashboardTopCategories,
|
||||
fetchDashboardTrends,
|
||||
type DashboardQuery,
|
||||
} from "../api/dashboard";
|
||||
import { fetchDepartments } from "../api/departments";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card } from "../components/ui/card";
|
||||
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) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return "加载失败,请稍后重试。";
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { me, permissions } = useAuth();
|
||||
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}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (!canViewDashboard) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="仪表盘" subtitle="你当前没有看板权限,请联系管理员开通。" />
|
||||
<Card className="space-y-2 p-6 text-sm">
|
||||
<p className="text-slate-700">当前用户:{me?.name ?? "-"}</p>
|
||||
<p className="text-slate-500">所需权限:report.dashboard.view</p>
|
||||
</Card>
|
||||
</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>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="仪表盘"
|
||||
subtitle="前端壳已完成初始化,后续业务模块将在此基础上扩展。"
|
||||
subtitle="费用概览、趋势、分类与审批效率。支持筛选与导出。"
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="card-std">
|
||||
<h3 className="card-title">当前用户</h3>
|
||||
<dl className="space-y-2 text-sm text-slate-700">
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-secondary">姓名</dt>
|
||||
<dd className="font-medium text-slate-900">{me?.name ?? "-"}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-secondary">员工号</dt>
|
||||
<dd className="font-medium text-slate-900">{me?.employeeNo ?? "-"}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-secondary">邮箱</dt>
|
||||
<dd className="font-medium text-slate-900">{me?.email ?? "-"}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-secondary">角色</dt>
|
||||
<dd className="font-medium text-slate-900">{me?.roleCodes?.join(" / ") ?? "-"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
{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}
|
||||
|
||||
<section className="card-std">
|
||||
<h3 className="card-title">权限摘要</h3>
|
||||
{permissions.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{permissions.map((permission) => (
|
||||
<span
|
||||
className="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary"
|
||||
key={permission}
|
||||
>
|
||||
{permission}
|
||||
</span>
|
||||
<Card className="space-y-4 p-6">
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-600" htmlFor="dashboard-date-from">
|
||||
开始日期
|
||||
</label>
|
||||
<input
|
||||
id="dashboard-date-from"
|
||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(event) => setDateFrom(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-600" htmlFor="dashboard-date-to">
|
||||
结束日期
|
||||
</label>
|
||||
<input
|
||||
id="dashboard-date-to"
|
||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(event) => setDateTo(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-600" htmlFor="dashboard-department-id">
|
||||
部门
|
||||
</label>
|
||||
<select
|
||||
id="dashboard-department-id"
|
||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
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>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card className="p-4">
|
||||
<p className="text-xs text-slate-500">报销单总数</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-slate-900">
|
||||
{loading ? "..." : overviewQuery.data?.totalCount ?? 0}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-xs text-slate-500">报销总金额</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-slate-900">
|
||||
{loading ? "..." : toCurrency(overviewQuery.data?.totalAmount)}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-xs text-slate-500">已付款金额</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-slate-900">
|
||||
{loading ? "..." : toCurrency(overviewQuery.data?.paidAmount)}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-xs text-slate-500">审批中数量</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-slate-900">
|
||||
{loading ? "..." : overviewQuery.data?.reviewingCount ?? 0}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card className="space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-slate-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-slate-600">
|
||||
<span>{item.date}</span>
|
||||
<span>
|
||||
{toCurrency(item.amount)} / {item.count} 笔
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-100">
|
||||
<div className="h-2 rounded-full bg-primary" style={{ width }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="card-desc">暂无权限信息。</p>
|
||||
<p className="text-sm text-slate-500">暂无趋势数据</p>
|
||||
)}
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<Card className="space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-slate-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-slate-600">
|
||||
<span>
|
||||
{item.name} ({item.code})
|
||||
</span>
|
||||
<span>
|
||||
{toCurrency(item.amount)} / {item.count} 笔
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-100">
|
||||
<div className="h-2 rounded-full bg-emerald-500" style={{ width }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">暂无分类统计</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-3">
|
||||
<EmptyStateCard
|
||||
title="我的报销"
|
||||
description="下一阶段接入报销列表与详情。"
|
||||
footer={<Link className="text-sm font-medium text-primary" to="/reimbursements">进入页面</Link>}
|
||||
/>
|
||||
<EmptyStateCard
|
||||
title="待我处理"
|
||||
description="下一阶段接入 workflow 待办列表与动作。"
|
||||
footer={<Link className="text-sm font-medium text-primary" to="/workflow/tasks">进入页面</Link>}
|
||||
/>
|
||||
<EmptyStateCard
|
||||
title="票据中心"
|
||||
description="下一阶段接入上传、签名 URL 与绑定流程。"
|
||||
footer={<Link className="text-sm font-medium text-primary" to="/invoice-pool">进入页面</Link>}
|
||||
/>
|
||||
</div>
|
||||
<Card className="space-y-2 p-6 text-sm">
|
||||
<h3 className="text-base font-semibold text-slate-900">审批效率</h3>
|
||||
<p className="text-slate-700">
|
||||
已统计通过单据:
|
||||
<span className="ml-1 font-medium text-slate-900">
|
||||
{approvalEfficiencyQuery.data?.approvedCount ?? 0}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-slate-700">
|
||||
平均审批耗时(小时):
|
||||
<span className="ml-1 font-medium text-slate-900">
|
||||
{approvalEfficiencyQuery.data?.averageHours?.toFixed(2) ?? "0.00"}
|
||||
</span>
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户