文件
hyb-oa/client/src/pages/ReimbursementDetailPage.tsx
T
wangsli 7a6f5573a6 feat(client): 完成 P3 业务页联动闭环(D6/D8/D10/D11)
- 待办:列表/详情 + approve/reject/return/transfer(D6)
- 报销:列表/详情 + create/submit/withdraw/pay(D8)
- 票据:列表/上传/OCR轮询/绑定/预览(D10)
- 统一:Api types + 错误映射 + react-query缓存失效(D11)
2026-02-08 23:08:41 +08:00

236 行
8.0 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 {
fetchReimbursementDetail,
payReimbursement,
submitReimbursement,
withdrawReimbursement,
} from "../api/reimbursements";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
export function ReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const { permissions } = useAuth();
const queryClient = useQueryClient();
const [withdrawComment, setWithdrawComment] = useState("申请撤回并补充说明后重提");
const [paymentVoucherNo, setPaymentVoucherNo] = useState("");
const [paymentDate, setPaymentDate] = useState("2026-02-08");
const [paymentChannel, setPaymentChannel] = useState<"bank" | "alipay" | "wechat" | "cash" | "other">(
"bank",
);
const [actionError, setActionError] = useState("");
const detailQuery = useQuery({
enabled: Boolean(reimbursementId),
queryKey: ["reimbursements", reimbursementId],
queryFn: () => fetchReimbursementDetail(reimbursementId),
});
const mutation = useMutation({
mutationFn: async (action: "submit" | "withdraw" | "pay") => {
if (!reimbursementId) {
throw new Error("missing reimbursementId");
}
if (action === "submit") {
return submitReimbursement(reimbursementId);
}
if (action === "withdraw") {
return withdrawReimbursement(reimbursementId, withdrawComment.trim());
}
return payReimbursement(reimbursementId, {
paymentVoucherNo: paymentVoucherNo.trim() || undefined,
paymentDate: paymentDate || undefined,
paymentChannel,
});
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
]);
setActionError("");
},
onError: (error) => {
if (isApiError(error)) {
setActionError(
`${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`,
);
return;
}
if (error instanceof Error) {
setActionError(error.message);
return;
}
setActionError("操作失败,请稍后重试");
},
});
if (!reimbursementId) {
return (
<section className="card-std">
<p className="text-sm text-red-600">缺少 reimbursementId 参数。</p>
</section>
);
}
if (detailQuery.isLoading) {
return (
<section className="card-std">
<p className="text-sm text-slate-500">加载报销详情中...</p>
</section>
);
}
if (detailQuery.isError || !detailQuery.data) {
const message = isApiError(detailQuery.error)
? `${detailQuery.error.code}: ${mapApiErrorMessage(
detailQuery.error.code,
detailQuery.error.details,
detailQuery.error.message,
)}`
: "加载报销详情失败";
return (
<section className="card-std space-y-3">
<p className="text-sm text-red-600">{message}</p>
<Link className="btn-secondary" to="/reimbursements">
返回报销列表
</Link>
</section>
);
}
const detail = detailQuery.data;
const canPay = hasPermission(permissions, "expense.finance.pay");
return (
<div className="space-y-6">
<PageHeader
title={`报销详情 ${detail.formNumber ?? detail.id}`}
subtitle={`状态:${detail.status}`}
actions={
<Link className="btn-secondary" to="/reimbursements">
返回列表
</Link>
}
/>
<section className="card-std space-y-2 text-sm">
<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>
</section>
<section className="card-std space-y-3">
<h3 className="text-base font-semibold text-slate-900">可执行动作</h3>
{actionError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{actionError}
</p>
) : null}
<div className="flex flex-wrap gap-2">
<button
className="btn-primary"
disabled={mutation.isPending || detail.status !== "draft"}
onClick={() => mutation.mutate("submit")}
type="button"
>
提交报销
</button>
</div>
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
<p className="text-sm font-medium text-slate-800">撤回报销</p>
<textarea
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
onChange={(event) => setWithdrawComment(event.target.value)}
rows={2}
value={withdrawComment}
/>
<button
className="btn-secondary"
disabled={mutation.isPending || detail.status !== "reviewing" || withdrawComment.trim().length < 1}
onClick={() => mutation.mutate("withdraw")}
type="button"
>
撤回
</button>
</div>
{canPay ? (
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
<p className="text-sm font-medium text-slate-800">财务支付</p>
<input
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
onChange={(event) => setPaymentVoucherNo(event.target.value)}
placeholder="付款凭证号"
value={paymentVoucherNo}
/>
<input
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
onChange={(event) => setPaymentDate(event.target.value)}
type="date"
value={paymentDate}
/>
<select
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
onChange={(event) =>
setPaymentChannel(event.target.value as "bank" | "alipay" | "wechat" | "cash" | "other")
}
value={paymentChannel}
>
<option value="bank">bank</option>
<option value="alipay">alipay</option>
<option value="wechat">wechat</option>
<option value="cash">cash</option>
<option value="other">other</option>
</select>
<button
className="btn-secondary"
disabled={mutation.isPending || !["approved", "paid"].includes(detail.status)}
onClick={() => mutation.mutate("pay")}
type="button"
>
执行支付
</button>
</div>
) : null}
</section>
<section className="card-std space-y-2">
<h3 className="text-base font-semibold text-slate-900">关联票据</h3>
{(detail.invoices ?? []).length ? (
<div className="space-y-2 text-sm">
{detail.invoices.map((item) => (
<div className="rounded-lg border border-slate-200 px-3 py-2" key={item.id}>
<div className="font-medium text-slate-900">{item.id}</div>
<div className="text-slate-600">
状态 {item.status} / 发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-slate-500">暂无关联票据</p>
)}
</section>
</div>
);
}