565 行
21 KiB
TypeScript
565 行
21 KiB
TypeScript
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>
|
||
);
|
||
}
|