R55 A1-A6:报销统计仪表盘与批量审批
这个提交包含在:
@@ -53,6 +53,17 @@ export type ReimbursementListItem = {
|
||||
projectNo: string;
|
||||
name: string;
|
||||
} | null;
|
||||
pendingWorkflow: {
|
||||
instanceId: string;
|
||||
stepId: string;
|
||||
allowedActions: Array<"approve" | "reject" | "return" | "transfer">;
|
||||
task: {
|
||||
id: string;
|
||||
stepName: string;
|
||||
assigneeId: string | null;
|
||||
assigneeRole: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
@@ -147,6 +158,49 @@ export type ReimbursementListQuery = {
|
||||
scope?: "own" | "all";
|
||||
};
|
||||
|
||||
export type ReimbursementStatsQuery = {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
departmentId?: string;
|
||||
};
|
||||
|
||||
export type ReimbursementStatsGranularity = "month" | "week";
|
||||
|
||||
export type ReimbursementStatsSummary = {
|
||||
totalCount: number;
|
||||
totalAmount: string;
|
||||
byStatus: Record<
|
||||
ReimbursementStatus,
|
||||
{
|
||||
count: number;
|
||||
totalAmount: string;
|
||||
}
|
||||
>;
|
||||
avgProcessingDays: number | null;
|
||||
};
|
||||
|
||||
export type ReimbursementStatsTrendItem = {
|
||||
period: string | null;
|
||||
count: number;
|
||||
amount: string;
|
||||
paidCount: number;
|
||||
paidAmount: string;
|
||||
};
|
||||
|
||||
export type ReimbursementTopExpenseTypeItem = {
|
||||
expenseTypeId: string;
|
||||
expenseTypeName: string;
|
||||
count: number;
|
||||
totalAmount: string;
|
||||
};
|
||||
|
||||
export type ReimbursementApprovalBottleneckItem = {
|
||||
assigneeId: string | null;
|
||||
assigneeName: string;
|
||||
pendingCount: number;
|
||||
oldestPendingDays: number;
|
||||
};
|
||||
|
||||
export type ReimbursementPayPayload = {
|
||||
paymentVoucherNo?: string;
|
||||
paymentDate?: string;
|
||||
@@ -214,6 +268,34 @@ export async function fetchReimbursements(query: ReimbursementListQuery = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchReimbursementStatsSummary(query: ReimbursementStatsQuery = {}) {
|
||||
return apiRequestData<ReimbursementStatsSummary>(
|
||||
`/api/reimbursements/stats/summary${toQueryString(query as Record<string, string | number | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchReimbursementStatsTrend(
|
||||
query: ReimbursementStatsQuery & { granularity?: ReimbursementStatsGranularity } = {},
|
||||
) {
|
||||
return apiRequestData<{ items: ReimbursementStatsTrendItem[] }>(
|
||||
`/api/reimbursements/stats/trend${toQueryString(query as Record<string, string | number | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchReimbursementTopExpenseTypes(
|
||||
query: ReimbursementStatsQuery & { limit?: number } = {},
|
||||
) {
|
||||
return apiRequestData<{ items: ReimbursementTopExpenseTypeItem[] }>(
|
||||
`/api/reimbursements/stats/top-expense-types${toQueryString(query as Record<string, string | number | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchReimbursementApprovalBottleneck(query: ReimbursementStatsQuery = {}) {
|
||||
return apiRequestData<{ items: ReimbursementApprovalBottleneckItem[] }>(
|
||||
`/api/reimbursements/stats/approval-bottleneck${toQueryString(query as Record<string, string | number | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchReimbursementDetail(reimbursementId: string) {
|
||||
return apiRequestData<ReimbursementDetail>(`/api/reimbursements/${reimbursementId}`);
|
||||
}
|
||||
|
||||
@@ -201,6 +201,24 @@ export type WorkflowApproverQuery = {
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export type WorkflowBatchActionResult = {
|
||||
taskId: string;
|
||||
outcome: "approved" | "rejected" | "skipped" | "error";
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type WorkflowBatchActionSummary = {
|
||||
approved: number;
|
||||
rejected?: number;
|
||||
skipped: number;
|
||||
errored: number;
|
||||
};
|
||||
|
||||
export type WorkflowBatchActionResponse = {
|
||||
results: WorkflowBatchActionResult[];
|
||||
summary: WorkflowBatchActionSummary;
|
||||
};
|
||||
|
||||
function toQueryString(query: Record<string, string | number | undefined>) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
@@ -255,3 +273,17 @@ export async function transferWorkflowTask(taskId: string, toApproverId: string,
|
||||
body: JSON.stringify({ toApproverId, reason }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function batchApproveWorkflowTasks(payload: { taskIds: string[]; comment?: string }) {
|
||||
return apiRequestData<WorkflowBatchActionResponse>("/api/workflow/batch-approve", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function batchRejectWorkflowTasks(payload: { taskIds: string[]; comment: string }) {
|
||||
return apiRequestData<WorkflowBatchActionResponse>("/api/workflow/batch-reject", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isApiError } from "../api/types";
|
||||
@@ -6,6 +6,11 @@ import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchMyDelegations } from "../api/delegations";
|
||||
import { exportReimbursements } from "../api/exports";
|
||||
import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements";
|
||||
import {
|
||||
batchApproveWorkflowTasks,
|
||||
batchRejectWorkflowTasks,
|
||||
type WorkflowBatchActionResponse,
|
||||
} from "../api/workflow";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { toast } from "sonner";
|
||||
@@ -17,31 +22,82 @@ import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { FilterBar, ListWorkspace, PrimaryTable } from "../components/patterns/ListWorkspace";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
type BatchAction = "approve" | "reject";
|
||||
|
||||
type BatchResultState = {
|
||||
action: BatchAction;
|
||||
summary: WorkflowBatchActionResponse["summary"];
|
||||
results: Array<
|
||||
WorkflowBatchActionResponse["results"][number] & {
|
||||
reimbursementId: string | null;
|
||||
formNumber: string | null;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value);
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown, fallback: string) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
function formatBatchOutcome(action: BatchAction, outcome: string) {
|
||||
if (outcome === "approved") {
|
||||
return "已通过";
|
||||
}
|
||||
if (outcome === "rejected") {
|
||||
return "已驳回";
|
||||
}
|
||||
if (outcome === "skipped") {
|
||||
return "已跳过";
|
||||
}
|
||||
if (outcome === "error") {
|
||||
return "失败";
|
||||
}
|
||||
return action === "approve" ? "已通过" : "已驳回";
|
||||
}
|
||||
|
||||
export function ReimbursementsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { permissions, me } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [exportError, setExportError] = useState("");
|
||||
const [batchError, setBatchError] = useState("");
|
||||
const [proxyOwnerEmployeeId, setProxyOwnerEmployeeId] = useState("");
|
||||
const [batchMode, setBatchMode] = useState(false);
|
||||
const [selectedReimbursementIds, setSelectedReimbursementIds] = useState<string[]>([]);
|
||||
const [batchDialogAction, setBatchDialogAction] = useState<BatchAction | null>(null);
|
||||
const [batchComment, setBatchComment] = useState("");
|
||||
const [batchResult, setBatchResult] = useState<BatchResultState | null>(null);
|
||||
|
||||
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
|
||||
const page = Number(searchParams.get("page") ?? "1") || 1;
|
||||
const scope = (searchParams.get("scope") as "own" | "all" | null) ?? "own";
|
||||
const canViewAll = hasPermission(permissions, "expense.view_all");
|
||||
const canApproveExpense = hasPermission(permissions, "expense.approve");
|
||||
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
||||
const isAdministration = (me?.roleCodes ?? []).includes("administration");
|
||||
const showProxyOwnerSelector = canProxySubmit && isAdministration;
|
||||
|
||||
const [proxyOwnerEmployeeId, setProxyOwnerEmployeeId] = useState("");
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["reimbursements", "list", status, page, scope],
|
||||
queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }),
|
||||
@@ -67,6 +123,33 @@ export function ReimbursementsPage() {
|
||||
);
|
||||
}, [items]);
|
||||
|
||||
const actionableItems = useMemo(
|
||||
() =>
|
||||
items.filter(
|
||||
(item) =>
|
||||
Boolean(item.pendingWorkflow?.task?.id) &&
|
||||
Boolean(item.pendingWorkflow?.allowedActions.includes("approve")),
|
||||
),
|
||||
[items],
|
||||
);
|
||||
const actionableIdSet = useMemo(
|
||||
() => new Set(actionableItems.map((item) => item.id)),
|
||||
[actionableItems],
|
||||
);
|
||||
const canShowBatchEntry =
|
||||
status === "reviewing" && scope === "all" && canApproveExpense && actionableItems.length > 0;
|
||||
const selectedItems = actionableItems.filter((item) => selectedReimbursementIds.includes(item.id));
|
||||
const allActionableSelected =
|
||||
actionableItems.length > 0 && actionableItems.every((item) => selectedReimbursementIds.includes(item.id));
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedReimbursementIds((current) => current.filter((id) => actionableIdSet.has(id)));
|
||||
if (!canShowBatchEntry) {
|
||||
setBatchMode(false);
|
||||
setBatchDialogAction(null);
|
||||
}
|
||||
}, [actionableIdSet, canShowBatchEntry]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createReimbursement({
|
||||
@@ -76,12 +159,8 @@ export function ReimbursementsPage() {
|
||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||
toast.success("报销单已创建");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
isApiError(err)
|
||||
? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`
|
||||
: "创建报销单失败",
|
||||
);
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error, "创建报销单失败"));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -92,20 +171,128 @@ export function ReimbursementsPage() {
|
||||
setExportError("");
|
||||
toast.success("导出完成");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (isApiError(err)) {
|
||||
setExportError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||
toast.error(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||
return;
|
||||
onError: (error) => {
|
||||
const message = resolveErrorMessage(error, "导出失败");
|
||||
setExportError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const batchMutation = useMutation({
|
||||
mutationFn: async (input: { action: BatchAction; comment: string; selectedItems: typeof actionableItems }) => {
|
||||
const taskIds = input.selectedItems
|
||||
.map((item) => item.pendingWorkflow?.task?.id ?? null)
|
||||
.filter((taskId): taskId is string => Boolean(taskId));
|
||||
|
||||
if (!taskIds.length) {
|
||||
throw new Error("请先选择至少一条可处理的报销待办。");
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : "导出失败";
|
||||
setExportError(msg);
|
||||
toast.error(msg);
|
||||
|
||||
const response =
|
||||
input.action === "approve"
|
||||
? await batchApproveWorkflowTasks({
|
||||
taskIds,
|
||||
comment: input.comment.trim() || undefined,
|
||||
})
|
||||
: await batchRejectWorkflowTasks({
|
||||
taskIds,
|
||||
comment: input.comment.trim(),
|
||||
});
|
||||
|
||||
return {
|
||||
action: input.action,
|
||||
response,
|
||||
selectedItems: input.selectedItems.map((item) => ({
|
||||
reimbursementId: item.id,
|
||||
formNumber: item.formNumber,
|
||||
taskId: item.pendingWorkflow?.task?.id ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
onSuccess: async (payload) => {
|
||||
const formByTaskId = new Map(
|
||||
payload.selectedItems
|
||||
.filter((item) => item.taskId)
|
||||
.map((item) => [item.taskId!, item]),
|
||||
);
|
||||
|
||||
setBatchResult({
|
||||
action: payload.action,
|
||||
summary: payload.response.summary,
|
||||
results: payload.response.results.map((item) => {
|
||||
const related = formByTaskId.get(item.taskId);
|
||||
return {
|
||||
...item,
|
||||
reimbursementId: related?.reimbursementId ?? null,
|
||||
formNumber: related?.formNumber ?? null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
setBatchDialogAction(null);
|
||||
setBatchComment("");
|
||||
setBatchMode(false);
|
||||
setSelectedReimbursementIds([]);
|
||||
setBatchError("");
|
||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||
toast.success(payload.action === "approve" ? "批量通过已执行" : "批量驳回已执行");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveErrorMessage(error, "批量处理失败");
|
||||
setBatchError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||
|
||||
function toggleBatchSelection(reimbursementId: string) {
|
||||
setSelectedReimbursementIds((current) =>
|
||||
current.includes(reimbursementId)
|
||||
? current.filter((id) => id !== reimbursementId)
|
||||
: [...current, reimbursementId],
|
||||
);
|
||||
}
|
||||
|
||||
function toggleSelectAllActionable() {
|
||||
if (allActionableSelected) {
|
||||
setSelectedReimbursementIds([]);
|
||||
return;
|
||||
}
|
||||
setSelectedReimbursementIds(actionableItems.map((item) => item.id));
|
||||
}
|
||||
|
||||
function openBatchDialog(action: BatchAction) {
|
||||
if (!selectedItems.length) {
|
||||
setBatchError("请先选择至少一条可批量处理的报销待办。");
|
||||
return;
|
||||
}
|
||||
setBatchError("");
|
||||
setBatchDialogAction(action);
|
||||
}
|
||||
|
||||
function handleCloseBatchDialog(open: boolean) {
|
||||
if (!open) {
|
||||
setBatchDialogAction(null);
|
||||
setBatchComment("");
|
||||
}
|
||||
}
|
||||
|
||||
function submitBatchAction() {
|
||||
if (!batchDialogAction) {
|
||||
return;
|
||||
}
|
||||
if (batchDialogAction === "reject" && !batchComment.trim()) {
|
||||
setBatchError("批量驳回必须填写统一理由。");
|
||||
return;
|
||||
}
|
||||
|
||||
batchMutation.mutate({
|
||||
action: batchDialogAction,
|
||||
comment: batchComment,
|
||||
selectedItems,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ListWorkspace>
|
||||
<PageHeader
|
||||
@@ -131,6 +318,12 @@ export function ReimbursementsPage() {
|
||||
</select>
|
||||
) : null}
|
||||
|
||||
{canViewAll ? (
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/reimbursements/dashboard">统计分析</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
data-testid="reimbursements-create"
|
||||
disabled={createMutation.isPending}
|
||||
@@ -187,6 +380,12 @@ export function ReimbursementsPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{batchError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{batchError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard title="当前列表" value={stats.currentPageCount} note={`共 ${listQuery.data?.total ?? 0} 条记录`} />
|
||||
<MetricCard title="当前金额" value={formatCurrency(stats.currentPageAmount)} note="按当前页与筛选统计" />
|
||||
@@ -221,30 +420,54 @@ export function ReimbursementsPage() {
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{canViewAll ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canViewAll ? (
|
||||
<>
|
||||
<Button
|
||||
data-testid="reimbursements-scope-own"
|
||||
variant={scope === "own" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
|
||||
type="button"
|
||||
>
|
||||
仅自己
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="reimbursements-scope-all"
|
||||
variant={scope === "all" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
|
||||
type="button"
|
||||
>
|
||||
全范围
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{canShowBatchEntry ? (
|
||||
<Button
|
||||
data-testid="reimbursements-scope-own"
|
||||
variant={scope === "own" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
|
||||
variant={batchMode ? "default" : "secondary"}
|
||||
onClick={() => {
|
||||
setBatchMode((current) => !current);
|
||||
setSelectedReimbursementIds([]);
|
||||
setBatchDialogAction(null);
|
||||
setBatchComment("");
|
||||
setBatchError("");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
仅自己
|
||||
{batchMode ? "退出批量模式" : "批量操作"}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="reimbursements-scope-all"
|
||||
variant={scope === "all" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
|
||||
type="button"
|
||||
>
|
||||
全范围
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batchMode ? (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
当前只可勾选你本人可处理的 reviewing 报销待办。批量通过可不填意见,批量驳回必须填写统一理由。
|
||||
</div>
|
||||
) : null}
|
||||
</FilterBar>
|
||||
|
||||
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
|
||||
{listQuery.isLoading ? <TableSkeleton columns={batchMode ? 7 : 6} rows={5} /> : null}
|
||||
|
||||
{listQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
@@ -261,9 +484,10 @@ export function ReimbursementsPage() {
|
||||
{!listQuery.isLoading && !listQuery.isError ? (
|
||||
<>
|
||||
<PrimaryTable>
|
||||
<table className="w-full text-left border-collapse" data-testid="reimbursements-table">
|
||||
<table className="w-full border-collapse text-left" data-testid="reimbursements-table">
|
||||
<thead className="table-header">
|
||||
<tr>
|
||||
{batchMode ? <th className="table-th w-14">选择</th> : null}
|
||||
<th className="table-th">单号</th>
|
||||
<th className="table-th">状态</th>
|
||||
<th className="table-th">金额</th>
|
||||
@@ -274,40 +498,73 @@ export function ReimbursementsPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-50">
|
||||
{items.length ? (
|
||||
items.map((item) => (
|
||||
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
|
||||
<td className="table-td">
|
||||
<div className="font-semibold text-zinc-900">{item.formNumber || item.id}</div>
|
||||
{item.formNumber && item.formNumber !== item.id && (
|
||||
<div className="text-xs text-zinc-500">{item.id}</div>
|
||||
)}
|
||||
{item.project ? <div className="text-xs text-zinc-500">项目:{item.project.projectNo} · {item.project.name}</div> : null}
|
||||
</td>
|
||||
<td className="table-td"><StatusBadge status={item.status} /></td>
|
||||
<td className="table-td-strong">
|
||||
{item.totalAmount != null
|
||||
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(Number(item.totalAmount))
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-6 rounded-full bg-zinc-100 flex items-center justify-center text-[10px] font-bold text-zinc-700 uppercase">
|
||||
{(item.owner?.name ?? "U").charAt(0)}
|
||||
items.map((item) => {
|
||||
const canSelectRow = Boolean(
|
||||
item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"),
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
|
||||
{batchMode ? (
|
||||
<td className="table-td">
|
||||
{canSelectRow ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedReimbursementIds.includes(item.id)}
|
||||
onChange={() => toggleBatchSelection(item.id)}
|
||||
aria-label={`选择报销单 ${item.formNumber ?? item.id}`}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
) : null}
|
||||
<td className="table-td">
|
||||
<div className="font-semibold text-zinc-900">{item.formNumber || item.id}</div>
|
||||
{item.formNumber && item.formNumber !== item.id ? (
|
||||
<div className="text-xs text-zinc-500">{item.id}</div>
|
||||
) : null}
|
||||
{item.project ? (
|
||||
<div className="text-xs text-zinc-500">
|
||||
项目:{item.project.projectNo} · {item.project.name}
|
||||
</div>
|
||||
) : null}
|
||||
{item.pendingWorkflow?.task?.id ? (
|
||||
<div className="text-xs text-amber-700">
|
||||
当前待办:{item.pendingWorkflow.task.stepName} · task {item.pendingWorkflow.task.id}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<StatusBadge status={item.status} />
|
||||
</td>
|
||||
<td className="table-td-strong">
|
||||
{item.totalAmount != null
|
||||
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(
|
||||
Number(item.totalAmount),
|
||||
)
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-6 items-center justify-center rounded-full bg-zinc-100 text-[10px] font-bold uppercase text-zinc-700">
|
||||
{(item.owner?.name ?? "U").charAt(0)}
|
||||
</div>
|
||||
<span className="font-medium text-zinc-900">{item.owner?.name ?? "-"}</span>
|
||||
</div>
|
||||
<span className="font-medium text-zinc-900">{item.owner?.name ?? "-"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
|
||||
<td className="table-td">
|
||||
<Link to={`/reimbursements/${item.id}`} className="btn-ghost" data-testid="reimbursement-detail-link">
|
||||
<span className="material-symbols-outlined text-[18px]">more_horiz</span>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
</td>
|
||||
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
|
||||
<td className="table-td">
|
||||
<Link to={`/reimbursements/${item.id}`} className="btn-ghost" data-testid="reimbursement-detail-link">
|
||||
<span className="material-symbols-outlined text-[18px]">more_horiz</span>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
<td className="py-8 text-center text-sm text-zinc-500" colSpan={batchMode ? 7 : 6}>
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
@@ -326,6 +583,118 @@ export function ReimbursementsPage() {
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{batchMode ? (
|
||||
<div className="fixed bottom-6 left-1/2 z-40 w-[min(760px,calc(100vw-2rem))] -translate-x-1/2">
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white p-4 shadow-lg">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="text-sm text-zinc-700">已选 {selectedItems.length} 项可处理报销待办</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="secondary" onClick={toggleSelectAllActionable}>
|
||||
{allActionableSelected ? "取消全选" : "全选可处理项"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => openBatchDialog("approve")}>
|
||||
批量通过
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" onClick={() => openBatchDialog("reject")}>
|
||||
批量驳回
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Dialog open={Boolean(batchDialogAction)} onOpenChange={handleCloseBatchDialog}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{batchDialogAction === "approve" ? "批量通过报销待办" : "批量驳回报销待办"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{batchDialogAction === "approve"
|
||||
? "统一意见可空。所有选中待办会按当前顺序逐条调用在线审批接口。"
|
||||
: "驳回必须填写统一理由,所有选中待办将复用这条说明。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-700">
|
||||
共 {selectedItems.length} 项:
|
||||
<div className="mt-2 max-h-40 space-y-1 overflow-auto">
|
||||
{selectedItems.map((item) => (
|
||||
<div key={item.id}>{item.formNumber ?? item.id}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-900" htmlFor="reimbursements-batch-comment">
|
||||
{batchDialogAction === "reject" ? "统一驳回理由" : "统一审批意见"}
|
||||
</label>
|
||||
<Textarea
|
||||
id="reimbursements-batch-comment"
|
||||
value={batchComment}
|
||||
onChange={(event) => setBatchComment(event.target.value)}
|
||||
placeholder={batchDialogAction === "reject" ? "请输入批量驳回理由" : "可选填写统一审批意见"}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" type="button" onClick={() => handleCloseBatchDialog(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={batchDialogAction === "reject" ? "destructive" : "default"}
|
||||
disabled={batchMutation.isPending || (batchDialogAction === "reject" && !batchComment.trim())}
|
||||
onClick={submitBatchAction}
|
||||
>
|
||||
{batchMutation.isPending
|
||||
? "处理中..."
|
||||
: batchDialogAction === "approve"
|
||||
? "确认批量通过"
|
||||
: "确认批量驳回"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(batchResult)} onOpenChange={(open) => !open && setBatchResult(null)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{batchResult?.action === "approve" ? "批量通过结果" : "批量驳回结果"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{batchResult
|
||||
? `成功 ${batchResult.summary.approved ?? 0},驳回 ${batchResult.summary.rejected ?? 0},跳过 ${batchResult.summary.skipped},失败 ${batchResult.summary.errored}`
|
||||
: "查看批量操作逐条执行结果。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[60vh] space-y-2 overflow-auto rounded-2xl border border-zinc-200 bg-zinc-50 p-4">
|
||||
{batchResult?.results.map((item) => (
|
||||
<div key={`${item.taskId}-${item.outcome}`} className="rounded-xl border border-zinc-200 bg-white px-4 py-3">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">{item.formNumber ?? item.reimbursementId ?? "-"}</div>
|
||||
<div className="text-xs text-zinc-500">taskId: {item.taskId}</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-zinc-700">
|
||||
{formatBatchOutcome(batchResult.action, item.outcome)}
|
||||
</div>
|
||||
</div>
|
||||
{item.reason ? <div className="mt-2 text-sm text-red-600">{item.reason}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={() => setBatchResult(null)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ListWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
TodosPage,
|
||||
PurchaseRequestsPage,
|
||||
ReimbursementDetailPage,
|
||||
ReimbursementDashboardPage,
|
||||
ReimbursementsPage,
|
||||
TravelRequestsPage,
|
||||
UserManagementPage,
|
||||
@@ -147,6 +148,15 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="reimbursements/dashboard"
|
||||
element={
|
||||
<RequirePermission anyOf={["expense.view_all"]}>
|
||||
{withRouteFallback(<ReimbursementDashboardPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="reimbursements/:reimbursementId"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ReimbursementDashboardPage as default } from "../../pages/ReimbursementDashboardPage";
|
||||
@@ -40,6 +40,7 @@ export const ProjectDetailPage = lazy(() => import("./ProjectDetailPage"));
|
||||
export const ContractsRegistryPage = lazy(() => import("./ContractsRegistryPage"));
|
||||
export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"));
|
||||
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
|
||||
export const ReimbursementDashboardPage = lazy(() => import("./ReimbursementDashboardPage"));
|
||||
export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage"));
|
||||
export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage"));
|
||||
export const UserManagementPage = lazy(() => import("./UserManagementPage"));
|
||||
|
||||
在新工单中引用
屏蔽一个用户