993 行
40 KiB
TypeScript
993 行
40 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { isApiError } from "../api/types";
|
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
|
import { fetchMyDelegations } from "../api/delegations";
|
|
import { exportReimbursements } from "../api/exports";
|
|
import { fetchReimbursementTemplates } from "../api/reimbursementTemplates";
|
|
import {
|
|
createReimbursement,
|
|
deleteReimbursement,
|
|
fetchLinkableTravelRequests,
|
|
fetchReimbursements,
|
|
type ReimbursementCreatePayload,
|
|
type ReimbursementListItem,
|
|
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";
|
|
import { downloadBlob } from "../lib/download";
|
|
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
|
import { Pagination } from "../components/common/Pagination";
|
|
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
|
import { StatusBadge } from "../components/common/StatusBadge";
|
|
import { TableSkeleton } from "../components/common/TableSkeleton";
|
|
import { FilterBar, ListWorkspace, PrimaryTable, StatStrip, WorkspaceHeader, WorkspaceNotice } 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,
|
|
DropdownMenuSeparator,
|
|
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 ExportFormat = "csv" | "xlsx";
|
|
|
|
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 navigate = useNavigate();
|
|
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 [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
|
|
const [exportConfirmFormat, setExportConfirmFormat] = useState<ExportFormat | 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 canCreateExpense = hasPermission(permissions, "expense.create");
|
|
const canDeleteExpense = hasPermission(permissions, "expense.delete_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 listQuery = useQuery({
|
|
queryKey: ["reimbursements", "list", status, page, scope],
|
|
queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }),
|
|
});
|
|
|
|
const proxyDelegationsQuery = useQuery({
|
|
enabled: showProxyOwnerSelector,
|
|
queryKey: ["delegations", "mine", "active"],
|
|
queryFn: () => fetchMyDelegations("active"),
|
|
});
|
|
|
|
const templateOptionsQuery = useQuery({
|
|
enabled: canCreateExpense,
|
|
queryKey: ["reimbursement-templates", "enabled", "create-menu"],
|
|
queryFn: () => fetchReimbursementTemplates({ enabled: true }),
|
|
});
|
|
|
|
const linkableTravelRequestsQuery = useQuery({
|
|
enabled: canCreateExpense,
|
|
queryKey: ["reimbursements", "linkable-travel-requests", proxyOwnerEmployeeId || me?.id || "self"],
|
|
queryFn: () =>
|
|
fetchLinkableTravelRequests({
|
|
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
|
|
}),
|
|
});
|
|
|
|
const items = listQuery.data?.items ?? [];
|
|
const stats = useMemo(() => {
|
|
return items.reduce(
|
|
(acc, item) => {
|
|
acc.currentPageCount += 1;
|
|
acc.currentPageAmount += Number(item.totalAmount ?? 0);
|
|
if (item.status === "reviewing") acc.reviewing += 1;
|
|
if (item.status === "paid") acc.paid += 1;
|
|
return acc;
|
|
},
|
|
{ currentPageCount: 0, currentPageAmount: 0, reviewing: 0, paid: 0 },
|
|
);
|
|
}, [items]);
|
|
|
|
const templateOptions = templateOptionsQuery.data?.items ?? [];
|
|
const linkableTravelRequests = linkableTravelRequestsQuery.data?.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: (payload: ReimbursementCreatePayload) => createReimbursement(payload),
|
|
onSuccess: async (response, payload) => {
|
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
|
if (payload.linkedTravelRequestId) {
|
|
toast.success("已按差旅申请创建报销单");
|
|
} else if (payload.templateId) {
|
|
toast.success("已按模板创建报销单");
|
|
} else {
|
|
toast.success("报销单已创建");
|
|
}
|
|
navigate(`/reimbursements/${response.id}`);
|
|
},
|
|
onError: (error) => {
|
|
toast.error(resolveErrorMessage(error, "创建报销单失败"));
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (reimbursementId: string) => deleteReimbursement(reimbursementId),
|
|
onSuccess: async () => {
|
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
|
toast.success("草稿已删除");
|
|
},
|
|
onError: (error) => {
|
|
toast.error(resolveErrorMessage(error, "删除草稿失败"));
|
|
},
|
|
});
|
|
|
|
const handleDeleteDraft = (item: ReimbursementListItem) => {
|
|
setDeleteTarget(item);
|
|
};
|
|
|
|
const exportMutation = useMutation({
|
|
mutationFn: (format: ExportFormat) => exportReimbursements({ format, status, scope }),
|
|
onSuccess: (response, format) => {
|
|
downloadBlob(response.blob, response.filename ?? `reimbursements.${format}`);
|
|
setExportError("");
|
|
toast.success("导出完成");
|
|
},
|
|
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 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,
|
|
});
|
|
}
|
|
|
|
function startCreateReimbursement(payload: Omit<ReimbursementCreatePayload, "ownerEmployeeId"> = {}) {
|
|
createMutation.mutate({
|
|
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
|
|
...payload,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<ListWorkspace>
|
|
<WorkspaceHeader
|
|
eyebrow="报销"
|
|
title={scope === "all" ? "报销列表" : "我的报销"}
|
|
subtitle="先看状态和金额,再继续编辑、查看详情、导出或批量处理。"
|
|
actions={
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
{canCreateExpense ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
data-testid="reimbursements-create"
|
|
disabled={createMutation.isPending}
|
|
type="button"
|
|
>
|
|
{createMutation.isPending ? "创建中..." : "新建报销"}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-80">
|
|
<DropdownMenuItem onSelect={() => startCreateReimbursement()}>
|
|
空白报销
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
|
|
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
|
|
从模板创建
|
|
</div>
|
|
{templateOptionsQuery.isLoading ? (
|
|
<DropdownMenuItem disabled>正在加载模板...</DropdownMenuItem>
|
|
) : templateOptions.length ? (
|
|
templateOptions.map((template) => (
|
|
<DropdownMenuItem
|
|
key={template.id}
|
|
onSelect={() => startCreateReimbursement({ templateId: template.id })}
|
|
>
|
|
{template.name}
|
|
</DropdownMenuItem>
|
|
))
|
|
) : (
|
|
<DropdownMenuItem disabled>暂无可用模板</DropdownMenuItem>
|
|
)}
|
|
|
|
<DropdownMenuSeparator />
|
|
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
|
|
关联差旅申请
|
|
</div>
|
|
{linkableTravelRequestsQuery.isLoading ? (
|
|
<DropdownMenuItem disabled>正在加载差旅申请...</DropdownMenuItem>
|
|
) : linkableTravelRequests.length ? (
|
|
linkableTravelRequests.map((travelRequest) => (
|
|
<DropdownMenuItem
|
|
key={travelRequest.id}
|
|
onSelect={() => startCreateReimbursement({ linkedTravelRequestId: travelRequest.id })}
|
|
>
|
|
{travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose}
|
|
</DropdownMenuItem>
|
|
))
|
|
) : (
|
|
<DropdownMenuItem disabled>暂无可关联的差旅申请</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : null}
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
{createMutation.error && isApiError(createMutation.error) ? (
|
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
|
{createMutation.error.code}:
|
|
{mapApiErrorMessage(
|
|
createMutation.error.code,
|
|
createMutation.error.details,
|
|
createMutation.error.message,
|
|
)}
|
|
</p>
|
|
) : null}
|
|
|
|
{templateOptionsQuery.isError ? (
|
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
|
{resolveErrorMessage(templateOptionsQuery.error, "加载报销模板失败")}
|
|
</p>
|
|
) : null}
|
|
|
|
{linkableTravelRequestsQuery.isError ? (
|
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
|
{resolveErrorMessage(linkableTravelRequestsQuery.error, "加载可关联差旅申请失败")}
|
|
</p>
|
|
) : null}
|
|
|
|
{exportError ? (
|
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
|
{exportError}
|
|
</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}
|
|
|
|
<StatStrip columns={4} className="ops-status-strip">
|
|
<MetricCard title="当前列表" value={stats.currentPageCount} note={`共 ${listQuery.data?.total ?? 0} 条`} />
|
|
<MetricCard title="当前金额" value={formatCurrency(stats.currentPageAmount)} note="当前页" />
|
|
<MetricCard title="审批中" value={stats.reviewing} note="待推进" tone={stats.reviewing > 0 ? "warning" : "neutral"} />
|
|
<MetricCard title="已支付" value={stats.paid} note={scope === "all" ? "全范围" : "仅自己"} />
|
|
</StatStrip>
|
|
|
|
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
|
|
<div className="ops-command-bar__row">
|
|
<Tabs
|
|
data-testid="reimbursements-status-tabs"
|
|
value={status}
|
|
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
|
|
>
|
|
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
|
{[
|
|
{ value: "draft", label: "草稿" },
|
|
{ value: "reviewing", label: "审批中" },
|
|
{ value: "approved", label: "已通过" },
|
|
{ value: "paid", label: "已支付" },
|
|
{ value: "rejected", label: "已驳回" },
|
|
{ value: "cancelled", label: "已取消" },
|
|
].map((item) => (
|
|
<TabsTrigger
|
|
data-testid={`reimbursements-tab-${item.value}`}
|
|
key={item.value}
|
|
value={item.value}
|
|
>
|
|
{item.label}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
<div className="ops-command-bar__actions">
|
|
{showProxyOwnerSelector ? (
|
|
<select
|
|
className="select-sm"
|
|
data-testid="reimbursements-owner-select"
|
|
onChange={(event) => setProxyOwnerEmployeeId(event.target.value)}
|
|
value={proxyOwnerEmployeeId}
|
|
>
|
|
<option value="">自己</option>
|
|
{(proxyDelegationsQuery.data?.received ?? [])
|
|
.filter((item) => item.status === "active")
|
|
.map((item) => (
|
|
<option key={item.id} value={item.principalEmployeeId}>
|
|
{item.principal?.name ?? item.principalEmployeeId}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : null}
|
|
|
|
{canViewAll ? (
|
|
<>
|
|
<Button
|
|
data-testid="reimbursements-scope-own"
|
|
size="sm"
|
|
variant={scope === "own" ? "default" : "secondary"}
|
|
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
|
|
type="button"
|
|
>
|
|
仅自己
|
|
</Button>
|
|
<Button
|
|
data-testid="reimbursements-scope-all"
|
|
size="sm"
|
|
variant={scope === "all" ? "default" : "secondary"}
|
|
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
|
|
type="button"
|
|
>
|
|
全范围
|
|
</Button>
|
|
<Button asChild size="sm" variant="secondary" type="button">
|
|
<Link to="/reimbursements/dashboard">统计分析</Link>
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
data-testid="reimbursements-export"
|
|
size="sm"
|
|
variant="secondary"
|
|
type="button"
|
|
disabled={exportMutation.isPending}
|
|
>
|
|
{exportMutation.isPending ? "导出中..." : "导出"}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
data-testid="reimbursements-export-csv"
|
|
onSelect={() => setExportConfirmFormat("csv")}
|
|
>
|
|
导出 CSV
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
data-testid="reimbursements-export-xlsx"
|
|
onSelect={() => setExportConfirmFormat("xlsx")}
|
|
>
|
|
导出 Excel
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{canShowBatchEntry ? (
|
|
<Button
|
|
size="sm"
|
|
variant={batchMode ? "default" : "secondary"}
|
|
onClick={() => {
|
|
setBatchMode((current) => !current);
|
|
setSelectedReimbursementIds([]);
|
|
setBatchDialogAction(null);
|
|
setBatchComment("");
|
|
setBatchError("");
|
|
}}
|
|
type="button"
|
|
>
|
|
{batchMode ? "退出批量模式" : "批量操作"}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{batchMode ? (
|
|
<div className="ops-inline-alert border-amber-200 bg-amber-50 text-amber-800">
|
|
仅勾选你可处理的审批中报销;驳回需填写理由。
|
|
</div>
|
|
) : null}
|
|
</FilterBar>
|
|
|
|
{status === "draft" ? (
|
|
<WorkspaceNotice title="草稿可以继续编辑或删除" tone="neutral">
|
|
提交后的单据进入审批链;已通过、已支付单据不再提供删除入口。
|
|
</WorkspaceNotice>
|
|
) : status === "reviewing" ? (
|
|
<WorkspaceNotice title="审批中单据以详情页为主" tone="neutral">
|
|
查看当前节点、票据、附件和审批历史后再处理;有批量权限时只会选择你可处理的待办。
|
|
</WorkspaceNotice>
|
|
) : 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">
|
|
{isApiError(listQuery.error)
|
|
? `${listQuery.error.code}: ${mapApiErrorMessage(
|
|
listQuery.error.code,
|
|
listQuery.error.details,
|
|
listQuery.error.message,
|
|
)}`
|
|
: "加载报销列表失败"}
|
|
</p>
|
|
) : null}
|
|
|
|
{!listQuery.isLoading && !listQuery.isError ? (
|
|
<>
|
|
<div className="space-y-3 md:hidden" data-testid="reimbursements-mobile-list">
|
|
{items.length ? (
|
|
items.map((item) => {
|
|
const canSelectRow = Boolean(
|
|
item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"),
|
|
);
|
|
const actionLabel = ["draft", "rejected"].includes(item.status) ? "继续编辑" : "查看详情";
|
|
const displayCode = resolveReimbursementDisplayCode(item);
|
|
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
|
|
|
|
return (
|
|
<article
|
|
className="card-std space-y-3 p-4"
|
|
data-testid="reimbursement-mobile-card"
|
|
key={item.id}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm font-semibold text-zinc-900">
|
|
{displayCode}
|
|
</div>
|
|
<div className="mt-1 text-xs text-zinc-500">{item.reimbursementDate ?? "未填写日期"}</div>
|
|
</div>
|
|
<StatusBadge className="shrink-0" status={item.status} />
|
|
</div>
|
|
|
|
{item.project ? (
|
|
<div className="rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
|
项目:{item.project.projectNo} · {item.project.name}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
<div>
|
|
<div className="text-xs text-zinc-500">金额</div>
|
|
<div className="font-semibold text-zinc-900">
|
|
{item.totalAmount != null
|
|
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(
|
|
Number(item.totalAmount),
|
|
)
|
|
: "-"}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-zinc-500">报销人</div>
|
|
<div className="truncate font-medium text-zinc-900">{item.owner?.name ?? "-"}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{item.pendingWorkflow?.task?.id ? (
|
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
|
待办:{item.pendingWorkflow.task.stepName}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex items-center gap-2 pt-1">
|
|
{batchMode ? (
|
|
<label className="flex min-h-10 items-center gap-2 rounded-lg border border-zinc-200 px-3 text-sm text-zinc-700">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedReimbursementIds.includes(item.id)}
|
|
disabled={!canSelectRow}
|
|
onChange={() => toggleBatchSelection(item.id)}
|
|
aria-label={`选择报销单 ${displayCode}`}
|
|
/>
|
|
选择
|
|
</label>
|
|
) : null}
|
|
<Button
|
|
asChild
|
|
className="min-h-11 flex-1"
|
|
size="sm"
|
|
variant={["draft", "rejected"].includes(item.status) ? "default" : "secondary"}
|
|
>
|
|
<Link
|
|
to={`/reimbursements/${item.id}`}
|
|
data-testid="reimbursement-mobile-detail-link"
|
|
>
|
|
{actionLabel}
|
|
</Link>
|
|
</Button>
|
|
{canDeleteDraft ? (
|
|
<Button
|
|
className="min-h-11"
|
|
disabled={deleteMutation.isPending}
|
|
onClick={() => handleDeleteDraft(item)}
|
|
size="sm"
|
|
type="button"
|
|
variant="destructive"
|
|
>
|
|
删除
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</article>
|
|
);
|
|
})
|
|
) : (
|
|
<div className="rounded-xl border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500">
|
|
暂无数据
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<PrimaryTable className="hidden md:block ops-table-focus">
|
|
<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>
|
|
<th className="table-th">报销人</th>
|
|
<th className="table-th">日期</th>
|
|
<th className="table-th w-24">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-zinc-50">
|
|
{items.length ? (
|
|
items.map((item) => {
|
|
const canSelectRow = Boolean(
|
|
item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"),
|
|
);
|
|
const displayCode = resolveReimbursementDisplayCode(item);
|
|
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
|
|
|
|
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={`选择报销单 ${displayCode}`}
|
|
/>
|
|
) : (
|
|
<span className="text-xs text-zinc-400">-</span>
|
|
)}
|
|
</td>
|
|
) : null}
|
|
<td className="table-td">
|
|
<div className="font-semibold text-zinc-900">{displayCode}</div>
|
|
{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}
|
|
</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>
|
|
</td>
|
|
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
|
|
<td className="table-td">
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
asChild
|
|
size="sm"
|
|
variant={["draft", "rejected"].includes(item.status) ? "default" : "secondary"}
|
|
>
|
|
<Link
|
|
to={`/reimbursements/${item.id}`}
|
|
data-testid="reimbursement-detail-link"
|
|
>
|
|
{["draft", "rejected"].includes(item.status) ? "编辑" : "详情"}
|
|
</Link>
|
|
</Button>
|
|
{canDeleteDraft ? (
|
|
<Button
|
|
disabled={deleteMutation.isPending}
|
|
onClick={() => handleDeleteDraft(item)}
|
|
size="sm"
|
|
type="button"
|
|
variant="destructive"
|
|
>
|
|
删除
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
) : (
|
|
<tr>
|
|
<td className="py-8 text-center text-sm text-zinc-500" colSpan={batchMode ? 7 : 6}>
|
|
暂无数据
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</PrimaryTable>
|
|
|
|
<div className="mt-4">
|
|
<Pagination
|
|
page={page}
|
|
totalPages={totalPages}
|
|
onPageChange={(p) => setSearchParams({ status, scope, page: String(p) })}
|
|
testIdPrefix="reimbursements"
|
|
/>
|
|
</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-lg border border-zinc-200 bg-white p-3.5 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-lg border border-zinc-200 bg-zinc-50 p-3.5 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}>{resolveReimbursementDisplayCode(item)}</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-lg border border-zinc-200 bg-zinc-50 p-3.5">
|
|
{batchResult?.results.map((item) => (
|
|
<div key={`${item.taskId}-${item.outcome}`} className="rounded-lg 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">
|
|
{resolveReimbursementDisplayCode({ formNumber: item.formNumber, id: 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>
|
|
<ActionConfirmDialog
|
|
open={Boolean(deleteTarget)}
|
|
onOpenChange={(open) => {
|
|
if (!open) setDeleteTarget(null);
|
|
}}
|
|
kind="delete"
|
|
targetName={deleteTarget ? resolveReimbursementDisplayCode(deleteTarget) : undefined}
|
|
description="删除后无法恢复,已提交或审批中的报销单不会受到影响。"
|
|
onConfirm={async () => {
|
|
if (deleteTarget) {
|
|
deleteMutation.mutate(deleteTarget.id);
|
|
}
|
|
}}
|
|
/>
|
|
<ActionConfirmDialog
|
|
open={Boolean(exportConfirmFormat)}
|
|
onOpenChange={(open) => {
|
|
if (!open) setExportConfirmFormat(null);
|
|
}}
|
|
kind="sensitiveExport"
|
|
targetName={exportConfirmFormat === "xlsx" ? "报销 Excel" : "报销 CSV"}
|
|
onConfirm={async () => {
|
|
if (exportConfirmFormat) {
|
|
exportMutation.mutate(exportConfirmFormat);
|
|
}
|
|
}}
|
|
/>
|
|
</ListWorkspace>
|
|
);
|
|
}
|