R55 A1-A6:报销统计仪表盘与批量审批
这个提交包含在:
@@ -0,0 +1,343 @@
|
||||
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<ReimbursementStatsQuery>(
|
||||
() => ({
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="报销统计分析"
|
||||
subtitle="按时间范围与部门查看报销规模、处理效率、费用结构和审批瓶颈。"
|
||||
actions={
|
||||
<Button type="button" variant="secondary" onClick={() => void handleRefresh()}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{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="reimbursement-dashboard-date-from">
|
||||
开始日期
|
||||
</label>
|
||||
<input
|
||||
id="reimbursement-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="reimbursement-dashboard-date-to">
|
||||
结束日期
|
||||
</label>
|
||||
<input
|
||||
id="reimbursement-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="reimbursement-dashboard-department">
|
||||
部门
|
||||
</label>
|
||||
<select
|
||||
id="reimbursement-dashboard-department"
|
||||
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">
|
||||
<div className="rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-600">
|
||||
默认窗口:近 3 个自然月
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="card-std p-5">
|
||||
<div className="text-xs text-zinc-500">报销总数</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-zinc-900">
|
||||
{loading ? "..." : summary?.totalCount ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-std p-5">
|
||||
<div className="text-xs text-zinc-500">报销总金额</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-zinc-900">
|
||||
{loading ? "..." : formatCurrency(summary?.totalAmount)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-std p-5">
|
||||
<div className="text-xs text-zinc-500">平均处理天数</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-zinc-900">
|
||||
{loading ? "..." : formatAvgDays(summary?.avgProcessingDays ?? null)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-std p-5">
|
||||
<div className="text-xs text-zinc-500">待审批数</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-zinc-900">
|
||||
{loading ? "..." : summary?.byStatus.reviewing.count ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionPanel
|
||||
title="报销趋势"
|
||||
description="按月观察报销数量与金额的变化,帮助判断高峰月份和支付节奏。"
|
||||
>
|
||||
{trendItems.length ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{trendItems.map((item) => (
|
||||
<div key={item.period ?? "unknown"} className="rounded-2xl border border-zinc-200 bg-white p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-zinc-900">{formatPeriod(item.period)}</div>
|
||||
<div className="text-xs text-zinc-500">已支付 {item.paidCount} 单</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-zinc-500">
|
||||
<span>数量</span>
|
||||
<span>{item.count} 单</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-zinc-100">
|
||||
<div
|
||||
className="h-2 rounded-full bg-zinc-900"
|
||||
style={{ width: normalizeBarWidth(Number(item.count ?? 0), maxTrendCount) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-zinc-500">
|
||||
<span>金额</span>
|
||||
<span>{formatCurrency(item.amount)}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-emerald-100">
|
||||
<div
|
||||
className="h-2 rounded-full bg-emerald-600"
|
||||
style={{ width: normalizeBarWidth(Number(item.amount ?? 0), maxTrendAmount) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500">当前筛选下暂无趋势数据。</div>
|
||||
)}
|
||||
</SectionPanel>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionPanel
|
||||
title="费用类型排行"
|
||||
description="按金额查看最主要的费用类型,帮助财务快速定位结构变化。"
|
||||
>
|
||||
{topExpenseTypes.length ? (
|
||||
<div className="space-y-3">
|
||||
{topExpenseTypes.map((item, index) => (
|
||||
<div key={item.expenseTypeId} className="rounded-2xl border border-zinc-200 bg-white p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-zinc-900">
|
||||
{index + 1}. {item.expenseTypeName}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{item.count} 条报销明细</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-zinc-700">{formatCurrency(item.totalAmount)}</div>
|
||||
</div>
|
||||
<div className="mt-3 h-2 rounded-full bg-zinc-100">
|
||||
<div
|
||||
className="h-2 rounded-full bg-amber-500"
|
||||
style={{ width: normalizeBarWidth(Number(item.totalAmount ?? 0), maxExpenseTypeAmount) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500">当前筛选下暂无费用类型排行。</div>
|
||||
)}
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel
|
||||
title="审批瓶颈"
|
||||
description="观察 reviewing 中的待处理堆积情况,帮助尽快发现卡点。"
|
||||
>
|
||||
{bottlenecks.length ? (
|
||||
<div className="space-y-3">
|
||||
{bottlenecks.map((item) => {
|
||||
const isStale = item.oldestPendingDays > 3;
|
||||
return (
|
||||
<div key={`${item.assigneeId ?? "role"}-${item.assigneeName}`} className="rounded-2xl border border-zinc-200 bg-white p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-zinc-900">{item.assigneeName}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
待处理 {item.pendingCount} 单
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-sm font-medium ${isStale ? "text-red-600" : "text-zinc-700"}`}>
|
||||
最长等待 {item.oldestPendingDays} 天
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500">当前筛选下暂无审批瓶颈。</div>
|
||||
)}
|
||||
</SectionPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户