328 行
15 KiB
TypeScript
328 行
15 KiB
TypeScript
import { useMemo } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { QRCodeSVG } from "qrcode.react";
|
|
import { fetchReimbursementDetail } from "../api/reimbursements";
|
|
import { isApiError } from "../api/types";
|
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
|
import { useAuth } from "../auth/AuthContext";
|
|
import { CardSkeleton } from "../components/common/CardSkeleton";
|
|
import { Button } from "../components/ui/button";
|
|
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
|
|
|
function formatDate(value: string | null) {
|
|
return value || "-";
|
|
}
|
|
|
|
function formatDateTime(value: string | null) {
|
|
if (!value) {
|
|
return "-";
|
|
}
|
|
|
|
return value.slice(0, 19).replace("T", " ");
|
|
}
|
|
|
|
function formatActionLabel(action: string) {
|
|
const labels: Record<string, string> = {
|
|
start: "提交",
|
|
approve: "通过",
|
|
reject: "驳回",
|
|
return: "退回",
|
|
transfer: "转审",
|
|
withdraw: "撤回",
|
|
};
|
|
|
|
return labels[action] ?? action;
|
|
}
|
|
|
|
function formatCurrentPrintTime() {
|
|
return new Date().toLocaleString("zh-CN", { hour12: false });
|
|
}
|
|
|
|
export function ReimbursementPrintPage() {
|
|
const { reimbursementId = "" } = useParams();
|
|
const navigate = useNavigate();
|
|
const { me } = useAuth();
|
|
|
|
const detailQuery = useQuery({
|
|
enabled: Boolean(reimbursementId),
|
|
queryKey: ["reimbursements", reimbursementId, "print"],
|
|
queryFn: () => fetchReimbursementDetail(reimbursementId),
|
|
});
|
|
|
|
const printTime = useMemo(() => formatCurrentPrintTime(), []);
|
|
|
|
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={10} />;
|
|
}
|
|
|
|
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 type="button" variant="secondary" onClick={() => navigate(`/reimbursements/${reimbursementId}`)}>
|
|
返回详情
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const detail = detailQuery.data;
|
|
const totalAmount = detail.totalAmount ?? "0.00";
|
|
const qrValue = `https://oa.hyinda.com/reimbursements/${detail.id}`;
|
|
const displayCode = resolveReimbursementDisplayCode(detail);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-zinc-100 print:bg-white">
|
|
<style>{`
|
|
@page {
|
|
size: A4;
|
|
margin: 2cm;
|
|
}
|
|
@media print {
|
|
body {
|
|
background: #fff;
|
|
}
|
|
}
|
|
`}</style>
|
|
|
|
<div className="sticky top-0 z-10 border-b border-zinc-200 bg-white/95 backdrop-blur print:hidden">
|
|
<div className="mx-auto flex max-w-5xl items-center justify-between gap-3 px-4 py-3">
|
|
<div>
|
|
<div className="text-sm font-semibold text-zinc-900">费用报销单打印视图</div>
|
|
<div className="text-xs text-zinc-500">浏览器可直接打印或另存为 PDF。</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button type="button" variant="secondary" onClick={() => navigate(`/reimbursements/${reimbursementId}`)}>
|
|
返回详情
|
|
</Button>
|
|
<Button type="button" onClick={() => window.print()}>
|
|
打印 / 保存为 PDF
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mx-auto max-w-5xl p-4 print:max-w-none print:p-0">
|
|
<div className="bg-white p-8 shadow-sm print:shadow-none">
|
|
<div className="flex items-start justify-between gap-6 border-b border-zinc-200 pb-6">
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-zinc-300 text-sm font-semibold text-zinc-500">
|
|
LOGO
|
|
</div>
|
|
<div>
|
|
<h1 className="text-3xl font-semibold tracking-[0.2em] text-zinc-900">费用报销单</h1>
|
|
<p className="mt-2 text-sm text-zinc-500">标准打印稿,仅用于归档与线下签批留存。</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-sm text-zinc-500">表单号</div>
|
|
<div className="mt-1 text-xl font-semibold text-zinc-900">{displayCode}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
|
<div className="rounded-xl border border-zinc-200 p-4">
|
|
<div className="grid gap-3 text-sm">
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-zinc-500">报销人</span>
|
|
<span className="font-medium text-zinc-900">{detail.owner?.name ?? "-"}</span>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-zinc-500">部门</span>
|
|
<span className="font-medium text-zinc-900">{detail.department?.name ?? "-"}</span>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-zinc-500">报销日期</span>
|
|
<span className="font-medium text-zinc-900">{formatDate(detail.reimbursementDate)}</span>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-zinc-500">提交日期</span>
|
|
<span className="font-medium text-zinc-900">{formatDateTime(detail.submittedAt)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-zinc-200 p-4">
|
|
<div className="grid gap-3 text-sm">
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-zinc-500">当前状态</span>
|
|
<span className="font-medium text-zinc-900">{detail.status}</span>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-zinc-500">提交人</span>
|
|
<span className="font-medium text-zinc-900">{detail.submitter?.name ?? "-"}</span>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-zinc-500">表单编号</span>
|
|
<span className="font-medium text-zinc-900">{displayCode}</span>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-zinc-500">总金额</span>
|
|
<span className="text-2xl font-semibold text-zinc-900">¥{totalAmount}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<section className="mt-8">
|
|
<div className="mb-3 text-base font-semibold text-zinc-900">报销明细</div>
|
|
<div className="overflow-hidden rounded-xl border border-zinc-200">
|
|
<table className="min-w-full border-collapse text-sm">
|
|
<thead className="bg-zinc-50">
|
|
<tr>
|
|
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">序号</th>
|
|
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">费用日期</th>
|
|
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">费用类型</th>
|
|
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">说明</th>
|
|
<th className="border-b border-zinc-200 px-3 py-2 text-right font-medium">金额</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{detail.items.map((item, index) => (
|
|
<tr key={item.id}>
|
|
<td className="border-b border-zinc-100 px-3 py-2">{index + 1}</td>
|
|
<td className="border-b border-zinc-100 px-3 py-2">{formatDate(item.expenseDate)}</td>
|
|
<td className="border-b border-zinc-100 px-3 py-2">{item.expenseType?.name ?? item.expenseTypeId}</td>
|
|
<td className="border-b border-zinc-100 px-3 py-2">{item.description ?? "-"}</td>
|
|
<td className="border-b border-zinc-100 px-3 py-2 text-right">¥{item.amount}</td>
|
|
</tr>
|
|
))}
|
|
<tr className="bg-zinc-50">
|
|
<td className="px-3 py-2 font-medium" colSpan={4}>合计</td>
|
|
<td className="px-3 py-2 text-right font-semibold">¥{totalAmount}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mt-8">
|
|
<div className="mb-3 text-base font-semibold text-zinc-900">发票清单</div>
|
|
<div className="overflow-hidden rounded-xl border border-zinc-200">
|
|
<table className="min-w-full border-collapse text-sm">
|
|
<thead className="bg-zinc-50">
|
|
<tr>
|
|
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">发票号</th>
|
|
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">发票日期</th>
|
|
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">卖方</th>
|
|
<th className="border-b border-zinc-200 px-3 py-2 text-right font-medium">金额</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{detail.invoices.length ? (
|
|
detail.invoices.map((invoice) => (
|
|
<tr key={invoice.id}>
|
|
<td className="border-b border-zinc-100 px-3 py-2">
|
|
{invoice.invoiceCode || "-"}-{invoice.invoiceNumber || "-"}
|
|
</td>
|
|
<td className="border-b border-zinc-100 px-3 py-2">{formatDate(invoice.invoiceDate)}</td>
|
|
<td className="border-b border-zinc-100 px-3 py-2">{invoice.sellerName || "-"}</td>
|
|
<td className="border-b border-zinc-100 px-3 py-2 text-right">¥{invoice.totalAmount ?? "-"}</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td className="px-3 py-3 text-zinc-500" colSpan={4}>暂无关联发票</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mt-8">
|
|
<div className="mb-3 text-base font-semibold text-zinc-900">审批记录</div>
|
|
<div className="overflow-hidden rounded-xl border border-zinc-200">
|
|
<table className="min-w-full border-collapse text-sm">
|
|
<thead className="bg-zinc-50">
|
|
<tr>
|
|
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">步骤</th>
|
|
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">审批人</th>
|
|
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">审批时间</th>
|
|
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">状态</th>
|
|
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">意见</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{detail.approvalHistory.length ? (
|
|
detail.approvalHistory.map((record) => (
|
|
<tr key={record.id}>
|
|
<td className="border-b border-zinc-100 px-3 py-2">{record.stepName ?? "-"}</td>
|
|
<td className="border-b border-zinc-100 px-3 py-2">{record.actorName || "-"}</td>
|
|
<td className="border-b border-zinc-100 px-3 py-2">{formatDateTime(record.createdAt)}</td>
|
|
<td className="border-b border-zinc-100 px-3 py-2">{formatActionLabel(record.action)}</td>
|
|
<td className="border-b border-zinc-100 px-3 py-2">{record.comment || "-"}</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td className="px-3 py-3 text-zinc-500" colSpan={5}>暂无审批记录</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
{(detail.linkedTravelRequest || detail.project) ? (
|
|
<section className="mt-8">
|
|
<div className="mb-3 text-base font-semibold text-zinc-900">关联信息</div>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="rounded-xl border border-zinc-200 p-4 text-sm">
|
|
<div className="text-zinc-500">关联差旅申请</div>
|
|
<div className="mt-1 font-medium text-zinc-900">
|
|
{detail.linkedTravelRequest
|
|
? `${detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id} · ${detail.linkedTravelRequest.purpose ?? "未填写目的"}`
|
|
: "未关联"}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-xl border border-zinc-200 p-4 text-sm">
|
|
<div className="text-zinc-500">关联项目</div>
|
|
<div className="mt-1 font-medium text-zinc-900">
|
|
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
<section className="mt-8 flex items-end justify-between gap-6 border-t border-zinc-200 pt-6">
|
|
<div className="flex items-center gap-4">
|
|
<div className="rounded-xl border border-zinc-200 p-3">
|
|
<QRCodeSVG value={qrValue} size={96} includeMargin />
|
|
</div>
|
|
<div className="text-sm text-zinc-600">
|
|
<div className="font-medium text-zinc-900">扫码查看线上原件</div>
|
|
<div className="mt-1 break-all">{qrValue}</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-right text-sm text-zinc-500">
|
|
<div>打印时间:{printTime}</div>
|
|
<div className="mt-1">打印人:{me?.name ?? "-"}</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default ReimbursementPrintPage;
|