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

565 行
21 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchExpenseTypes } from "../api/expenseTypes";
import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations";
import {
createReimbursementItem,
deleteReimbursementItem,
fetchReimbursementDetail,
payReimbursement,
submitReimbursement,
updateReimbursementItem,
withdrawReimbursement,
type ReimbursementItem,
} from "../api/reimbursements";
import { toast } from "sonner";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { PageHeader } from "../components/common/PageHeader";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { Button } from "../components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
import {
ReimbursementItemEditor,
type ItemEditorInitialValues,
type ItemEditorSaveData,
} from "./reimbursement-detail/ReimbursementItemEditor";
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
function formatDateTime(value: string | null) {
if (!value) return "-";
return value.slice(0, 19).replace("T", " ");
}
export function ReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const { permissions } = useAuth();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState("");
const [itemEditor, setItemEditor] = useState<ItemEditorState>(null);
const [itemError, setItemError] = useState("");
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
expenseTypeId: "",
expenseDate: "",
amount: "",
description: "",
});
const detailQuery = useQuery({
enabled: Boolean(reimbursementId),
queryKey: ["reimbursements", reimbursementId],
queryFn: () => fetchReimbursementDetail(reimbursementId),
});
const expenseTypesQuery = useQuery({
queryKey: ["expenseTypes", "enabled"],
queryFn: () => fetchExpenseTypes({ enabled: true }),
});
const canManageIntegration = hasPermission(permissions, "integration.manage");
const erpPreviewQuery = useQuery({
enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid",
queryKey: ["reimbursements", reimbursementId, "erp-journal-preview"],
queryFn: () => previewReimbursementJournal(reimbursementId),
});
const mutation = useMutation({
mutationFn: async (params: { action: "submit" } | { action: "withdraw"; comment: string } | { action: "pay"; payload: PayPayload }) => {
if (!reimbursementId) {
throw new Error("missing reimbursementId");
}
if (params.action === "submit") {
return submitReimbursement(reimbursementId);
}
if (params.action === "withdraw") {
return withdrawReimbursement(reimbursementId, params.comment);
}
return payReimbursement(reimbursementId, {
paymentVoucherNo: params.payload.paymentVoucherNo || undefined,
paymentDate: params.payload.paymentDate || undefined,
paymentChannel: params.payload.paymentChannel,
});
},
onSuccess: async (_data, params) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
]);
setActionError("");
const actionMessages: Record<string, string> = { submit: "已提交", withdraw: "已撤回", pay: "付款成功" };
toast.success(actionMessages[params.action] ?? "操作成功");
},
onError: (error) => {
if (isApiError(error)) {
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setActionError(msg);
toast.error(msg);
return;
}
if (error instanceof Error) {
setActionError(error.message);
toast.error(error.message);
return;
}
setActionError("操作失败,请稍后重试");
toast.error("操作失败,请稍后重试");
},
});
const itemSaveMutation = useMutation({
mutationFn: async (data: ItemEditorSaveData) => {
if (!reimbursementId) {
throw new Error("missing reimbursementId");
}
if (!itemEditor) {
throw new Error("missing itemEditor");
}
const payload = {
expenseTypeId: data.expenseTypeId,
expenseDate: data.expenseDate,
amount: data.amount,
description: data.description.trim() || undefined,
};
if (itemEditor.mode === "create") {
return createReimbursementItem(reimbursementId, payload);
}
return updateReimbursementItem(reimbursementId, itemEditor.itemId, payload);
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
]);
setItemEditor(null);
setItemError("");
toast.success("明细已保存");
},
onError: (error) => {
if (isApiError(error)) {
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setItemError(msg);
toast.error(msg);
return;
}
if (error instanceof Error) {
setItemError(error.message);
toast.error(error.message);
return;
}
setItemError("保存明细失败,请稍后重试");
toast.error("保存明细失败,请稍后重试");
},
});
const itemDeleteMutation = useMutation({
mutationFn: async (itemId: string) => {
if (!reimbursementId) {
throw new Error("missing reimbursementId");
}
return deleteReimbursementItem(reimbursementId, itemId);
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
]);
setItemError("");
toast.success("明细已删除");
},
onError: (error) => {
if (isApiError(error)) {
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setItemError(msg);
toast.error(msg);
return;
}
if (error instanceof Error) {
setItemError(error.message);
toast.error(error.message);
return;
}
setItemError("删除明细失败,请稍后重试");
toast.error("删除明细失败,请稍后重试");
},
});
const erpExportMutation = useMutation({
mutationFn: async () => {
if (!reimbursementId) {
throw new Error("missing reimbursementId");
}
return exportReimbursementJournal(reimbursementId, true);
},
onSuccess: async (result) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "erp-journal-preview"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
]);
setActionError("");
const suffix = result.requeued ? "已重入队" : result.duplicated ? "已复用现有队列" : "已入队";
toast.success(`ERP 导出${suffix}`);
},
onError: (error) => {
if (isApiError(error)) {
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setActionError(msg);
toast.error(msg);
return;
}
if (error instanceof Error) {
setActionError(error.message);
toast.error(error.message);
return;
}
setActionError("ERP 导出失败,请稍后重试");
toast.error("ERP 导出失败,请稍后重试");
},
});
if (!reimbursementId) {
return (
<div className="card-std p-6">
<p className="text-sm text-red-600">缺少 reimbursementId 参数。</p>
</div>
);
}
if (detailQuery.isLoading) {
return <CardSkeleton lines={8} />;
}
if (detailQuery.isError || !detailQuery.data) {
const message = isApiError(detailQuery.error)
? `${detailQuery.error.code}: ${mapApiErrorMessage(
detailQuery.error.code,
detailQuery.error.details,
detailQuery.error.message,
)}`
: "加载报销详情失败";
return (
<div className="card-std space-y-3 p-6">
<p className="text-sm text-red-600">{message}</p>
<Button asChild variant="secondary">
<Link to="/reimbursements">返回报销列表</Link>
</Button>
</div>
);
}
const detail = detailQuery.data;
const budgetHint = detail.budgetHint;
const canPay = hasPermission(permissions, "expense.finance.pay");
const canEditItems = ["draft", "rejected"].includes(detail.status);
const expenseTypes = expenseTypesQuery.data?.items ?? [];
const computeEditorDefaults = (item?: ReimbursementItem): ItemEditorInitialValues => {
const defaultDate =
item?.expenseDate ??
detail.reimbursementDate ??
new Date().toISOString().slice(0, 10);
const defaultExpenseTypeId =
item?.expenseTypeId ?? expenseTypes[0]?.id ?? "";
return {
expenseDate: defaultDate,
expenseTypeId: defaultExpenseTypeId,
amount: item?.amount ?? "",
description: item?.description ?? "",
};
};
const startCreateItem = () => {
setItemEditor({ mode: "create" });
setEditorInitialValues(computeEditorDefaults());
setItemError("");
};
const startEditItem = (item: ReimbursementItem) => {
setItemEditor({ mode: "edit", itemId: item.id });
setEditorInitialValues(computeEditorDefaults(item));
setItemError("");
};
const handleItemSave = (data: ItemEditorSaveData) => {
itemSaveMutation.mutate(data);
};
const handleItemCancel = () => {
setItemEditor(null);
setItemError("");
};
return (
<div className="space-y-6">
<PageHeader
title={`报销详情 ${detail.formNumber ?? detail.id}`}
subtitle={`状态:${detail.status}`}
actions={
<Button asChild variant="secondary" data-testid="reimbursement-back">
<Link to="/reimbursements">返回列表</Link>
</Button>
}
/>
<div className="card-std space-y-2 p-6 text-sm" data-testid="reimbursement-summary">
<div>报销ID:{detail.id}</div>
<div>报销日期:{detail.reimbursementDate ?? "-"}</div>
<div>金额:{detail.totalAmount ?? "-"}</div>
<div>提交人:{detail.submitter?.name ?? "-"}</div>
<div>报销人:{detail.owner?.name ?? "-"}</div>
<div>说明:{detail.description || "-"}</div>
</div>
{budgetHint ? (
<div className="card-std space-y-2 p-6 text-sm" data-testid="reimbursement-budget-hint">
<h3 className="text-base font-semibold text-zinc-900">预算提示</h3>
<div>预算池:{budgetHint.budgetCode} / {budgetHint.budgetName}</div>
<div>
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount}
({budgetHint.nextUsagePercent.toFixed(2)}%)
</div>
<div>控制模式:{budgetHint.controlMode}</div>
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (
<p className="rounded-md border border-red-200 bg-red-50 px-2 py-1 text-red-600">
当前金额将超出预算上限,提交时会被阻断(block 模式)。
</p>
) : budgetHint.exceedsThreshold ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
当前金额已达到预算预警阈值,提交后会记录预算预警审计。
</p>
) : (
<p className="text-zinc-500">当前预算充足,可继续提交。</p>
)}
</div>
) : null}
{canManageIntegration && detail.status === "paid" ? (
<div className="card-std space-y-4 p-6" data-testid="reimbursement-erp-export">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">ERP 导出</h3>
<p className="text-sm text-zinc-500">已付款报销单会按标准凭证切片通过 Outbox 异步发送到 ERP。</p>
</div>
<Button onClick={() => erpExportMutation.mutate()} disabled={erpExportMutation.isPending}>
{erpExportMutation.isPending ? "导出中..." : "导出到 ERP"}
</Button>
</div>
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载凭证切片预览...</p> : null}
{erpPreviewQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(erpPreviewQuery.error)
? `${erpPreviewQuery.error.code}: ${mapApiErrorMessage(
erpPreviewQuery.error.code,
erpPreviewQuery.error.details,
erpPreviewQuery.error.message,
)}`
: "加载 ERP 导出预览失败"}
</p>
) : null}
{erpPreviewQuery.data ? (
<div className="grid gap-4 lg:grid-cols-2 text-sm">
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
<h4 className="font-medium text-zinc-900">凭证切片</h4>
<div>类型:{erpPreviewQuery.data.journal.journalType}</div>
<div>单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}</div>
<div>金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}</div>
<div>法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}</div>
<div>成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}</div>
<div>付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}</div>
<div>付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
<h4 className="font-medium text-zinc-900">导出状态</h4>
<div>外部ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
<div>Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div>
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
</div>
</div>
) : null}
</div>
) : null}
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
<div className="card-std space-y-4 p-6" data-testid="reimbursement-items">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
<p className="text-xs text-zinc-500">明细变更会自动重算总金额。</p>
</div>
{canEditItems ? (
<Button
data-testid="reimbursement-item-add"
disabled={itemSaveMutation.isPending || itemDeleteMutation.isPending}
onClick={startCreateItem}
type="button"
variant="secondary"
>
新增明细
</Button>
) : (
<p className="text-xs text-zinc-500">当前状态不可编辑明细</p>
)}
</div>
{itemError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
data-testid="reimbursement-item-error"
>
{itemError}
</p>
) : null}
<div className="rounded-lg border border-zinc-200">
<Table data-testid="reimbursement-items-table">
<TableHeader>
<tr>
<TableHead>费用类型</TableHead>
<TableHead>发生日期</TableHead>
<TableHead>金额</TableHead>
<TableHead>说明</TableHead>
<TableHead className="w-32">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{detail.items.length ? (
detail.items.map((item) => (
<TableRow data-testid="reimbursement-item-row" key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">
{item.expenseType?.name ?? item.expenseTypeId}
</div>
<div className="text-xs text-zinc-500">{item.expenseType?.code ?? "-"}</div>
</TableCell>
<TableCell>{item.expenseDate ?? "-"}</TableCell>
<TableCell>{item.amount}</TableCell>
<TableCell>{item.description ?? "-"}</TableCell>
<TableCell>
{canEditItems ? (
<div className="flex flex-wrap gap-2">
<Button
data-testid="reimbursement-item-edit"
size="sm"
type="button"
variant="secondary"
onClick={() => startEditItem(item)}
>
编辑
</Button>
<Button
data-testid="reimbursement-item-delete"
size="sm"
type="button"
variant="secondary"
disabled={itemDeleteMutation.isPending}
onClick={() => setConfirmDeleteItemId(item.id)}
>
删除
</Button>
</div>
) : null}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={5}>
暂无明细
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{itemEditor ? (
<ReimbursementItemEditor
mode={itemEditor.mode}
expenseTypes={expenseTypes}
expenseTypesLoading={expenseTypesQuery.isLoading}
expenseTypesError={expenseTypesQuery.isError}
initialValues={editorInitialValues}
onSave={handleItemSave}
onCancel={handleItemCancel}
isPending={itemSaveMutation.isPending}
/>
) : null}
</div>
<ReimbursementActions
status={detail.status}
canPay={canPay}
actionError={actionError}
isPending={mutation.isPending}
onSubmit={() => mutation.mutate({ action: "submit" })}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
onPay={(payload) => mutation.mutate({ action: "pay", payload })}
/>
<div className="card-std space-y-2 p-6" data-testid="reimbursement-invoices">
<h3 className="text-base font-semibold text-zinc-900">关联票据</h3>
{(detail.invoices ?? []).length ? (
<div className="space-y-2 text-sm">
{detail.invoices.map((item) => (
<div className="rounded-lg border border-zinc-200 px-3 py-2" key={item.id}>
<div className="font-medium text-zinc-900">{item.id}</div>
<div className="text-zinc-600">
状态 {item.status} / 发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-500">暂无关联票据</p>
)}
</div>
<ConfirmDialog
open={Boolean(confirmDeleteItemId)}
onOpenChange={(open) => { if (!open) setConfirmDeleteItemId(""); }}
title="确认删除该明细?"
variant="destructive"
confirmLabel="删除"
onConfirm={() => { itemDeleteMutation.mutate(confirmDeleteItemId); }}
/>
</div>
);
}