R58 A1-A6:报销详情页内嵌发票绑定与单据打印
这个提交包含在:
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isApiError } from "../api/types";
|
||||
@@ -19,6 +19,13 @@ import {
|
||||
linkReimbursementProject,
|
||||
type ReimbursementItem,
|
||||
} from "../api/reimbursements";
|
||||
import {
|
||||
bindInvoice,
|
||||
fetchBindableInvoices,
|
||||
fetchInvoicePoolDetail,
|
||||
unbindInvoice,
|
||||
uploadInvoice,
|
||||
} from "../api/invoicePool";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
@@ -35,6 +42,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||||
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||
@@ -50,12 +58,29 @@ import {
|
||||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||||
|
||||
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
|
||||
type SelectedInvoiceMap = Record<string, string>;
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) return "-";
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
function resolveErpSyncStatus(
|
||||
exportState:
|
||||
| {
|
||||
@@ -84,7 +109,7 @@ function resolveErpSyncStatus(
|
||||
|
||||
export function ReimbursementDetailPage() {
|
||||
const { reimbursementId = "" } = useParams();
|
||||
const { permissions } = useAuth();
|
||||
const { permissions, me } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [actionError, setActionError] = useState("");
|
||||
@@ -94,6 +119,13 @@ export function ReimbursementDetailPage() {
|
||||
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
||||
const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false);
|
||||
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
|
||||
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
|
||||
const [invoiceSearchInput, setInvoiceSearchInput] = useState("");
|
||||
const [invoiceSearch, setInvoiceSearch] = useState("");
|
||||
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
|
||||
const [selectedInvoiceFile, setSelectedInvoiceFile] = useState<File | null>(null);
|
||||
const [invoiceActionError, setInvoiceActionError] = useState("");
|
||||
const [pendingOcrInvoiceId, setPendingOcrInvoiceId] = useState<string | null>(null);
|
||||
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
|
||||
expenseTypeId: "",
|
||||
expenseDate: "",
|
||||
@@ -119,6 +151,9 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
const canManageIntegration = hasPermission(permissions, "integration.manage");
|
||||
const canSubmitExpense = hasPermission(permissions, "expense.submit");
|
||||
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
||||
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
|
||||
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
|
||||
|
||||
const reimbursementPrecheckQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId) && canSubmitExpense && ["draft", "rejected"].includes(detailQuery.data?.status ?? ""),
|
||||
@@ -132,6 +167,40 @@ export function ReimbursementDetailPage() {
|
||||
queryFn: () => previewReimbursementJournal(reimbursementId),
|
||||
});
|
||||
|
||||
const bindableInvoicesQuery = useQuery({
|
||||
enabled:
|
||||
Boolean(reimbursementId) &&
|
||||
isInvoiceDialogOpen &&
|
||||
canUploadInvoice &&
|
||||
canViewOwnInvoice &&
|
||||
Boolean(detailQuery.data) &&
|
||||
["draft", "rejected"].includes(detailQuery.data?.status ?? "") &&
|
||||
Boolean(me?.id) &&
|
||||
[detailQuery.data?.ownerId, detailQuery.data?.submitterId].includes(me?.id ?? ""),
|
||||
queryKey: ["invoice-pool", "bindable", reimbursementId, invoiceSearch],
|
||||
queryFn: () =>
|
||||
fetchBindableInvoices({
|
||||
reimbursementId,
|
||||
search: invoiceSearch || undefined,
|
||||
limit: 20,
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = window.setTimeout(() => {
|
||||
setInvoiceSearch(invoiceSearchInput.trim());
|
||||
}, 300);
|
||||
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, [invoiceSearchInput]);
|
||||
|
||||
useEffect(() => {
|
||||
const candidateIds = new Set((bindableInvoicesQuery.data?.items ?? []).map((item) => item.id));
|
||||
setSelectedInvoices((previous) =>
|
||||
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => candidateIds.has(invoiceId))),
|
||||
);
|
||||
}, [bindableInvoicesQuery.data?.items]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (params: { action: "submit" } | { action: "withdraw"; comment: string } | { action: "pay"; payload: PayPayload }) => {
|
||||
if (!reimbursementId) {
|
||||
@@ -327,6 +396,141 @@ export function ReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const bindInvoicesMutation = useMutation({
|
||||
mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => {
|
||||
if (!reimbursementId) {
|
||||
throw new Error("missing reimbursementId");
|
||||
}
|
||||
|
||||
const results: Array<{ invoiceId: string; success: boolean; message?: string }> = [];
|
||||
for (const selection of invoiceSelections) {
|
||||
try {
|
||||
await bindInvoice(selection.invoiceId, {
|
||||
reimbursementId,
|
||||
...(selection.linkedItemId ? { linkedItemId: selection.linkedItemId } : {}),
|
||||
});
|
||||
results.push({ invoiceId: selection.invoiceId, success: true });
|
||||
} catch (error) {
|
||||
results.push({
|
||||
invoiceId: selection.invoiceId,
|
||||
success: false,
|
||||
message: buildActionErrorMessage(error, "绑定发票失败"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
onSuccess: async (results) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }),
|
||||
]);
|
||||
|
||||
const failedResults = results.filter((item) => !item.success);
|
||||
const succeededIds = results.filter((item) => item.success).map((item) => item.invoiceId);
|
||||
|
||||
setSelectedInvoices((previous) =>
|
||||
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
|
||||
);
|
||||
|
||||
if (!failedResults.length) {
|
||||
setInvoiceActionError("");
|
||||
toast.success(`已绑定 ${succeededIds.length} 张发票`);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = failedResults
|
||||
.map((item) => `${item.invoiceId.slice(0, 8)}: ${item.message ?? "绑定失败"}`)
|
||||
.join(";");
|
||||
setInvoiceActionError(summary);
|
||||
if (succeededIds.length) {
|
||||
toast.success(`已绑定 ${succeededIds.length} 张,${failedResults.length} 张失败`);
|
||||
} else {
|
||||
toast.error("发票绑定失败");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = buildActionErrorMessage(error, "发票绑定失败");
|
||||
setInvoiceActionError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const unbindInvoiceMutation = useMutation({
|
||||
mutationFn: (invoiceId: string) => unbindInvoice(invoiceId),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }),
|
||||
]);
|
||||
setInvoiceActionError("");
|
||||
toast.success("已解绑发票");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = buildActionErrorMessage(error, "解绑发票失败");
|
||||
setInvoiceActionError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadInvoiceMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const ownerEmployeeId = detailQuery.data?.ownerId;
|
||||
if (!ownerEmployeeId) {
|
||||
throw new Error("missing ownerEmployeeId");
|
||||
}
|
||||
return uploadInvoice(file, undefined, ownerEmployeeId);
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
setInvoiceActionError("");
|
||||
setSelectedInvoiceFile(null);
|
||||
setInvoiceSearchInput("");
|
||||
setInvoiceSearch("");
|
||||
setPendingOcrInvoiceId(result.invoicePoolId);
|
||||
toast.success("发票上传成功,OCR 识别中");
|
||||
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(2_000);
|
||||
try {
|
||||
const invoice = await fetchInvoicePoolDetail(result.invoicePoolId);
|
||||
if (invoice.status === "available") {
|
||||
const refreshed = await bindableInvoicesQuery.refetch();
|
||||
if (refreshed.data?.items.some((item) => item.id === result.invoicePoolId)) {
|
||||
setSelectedInvoices((previous) => ({
|
||||
...previous,
|
||||
[result.invoicePoolId]: previous[result.invoicePoolId] ?? "",
|
||||
}));
|
||||
}
|
||||
setPendingOcrInvoiceId(null);
|
||||
toast.success("发票 OCR 已完成,可直接绑定");
|
||||
return;
|
||||
}
|
||||
|
||||
if (invoice.status === "ocr_failed") {
|
||||
setPendingOcrInvoiceId(null);
|
||||
toast.error("发票 OCR 识别失败,请前往票据中心处理");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Polling errors should not block eventual refresh; keep retrying until timeout.
|
||||
}
|
||||
}
|
||||
|
||||
setPendingOcrInvoiceId(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] });
|
||||
toast("发票仍在 OCR 处理中,请稍后手动刷新候选列表");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = buildActionErrorMessage(error, "上传发票失败");
|
||||
setInvoiceActionError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
if (!reimbursementId) {
|
||||
return (
|
||||
<div className="card-std p-6">
|
||||
@@ -364,9 +568,17 @@ export function ReimbursementDetailPage() {
|
||||
const budgetHint = detail.budgetHint;
|
||||
const canPay = hasPermission(permissions, "expense.finance.pay");
|
||||
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
||||
const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? "");
|
||||
const canOpenInvoiceBindDialog =
|
||||
canEditItems &&
|
||||
isReimbursementParticipant &&
|
||||
canUploadInvoice &&
|
||||
canViewOwnInvoice;
|
||||
const canUnbindInvoices = canEditItems && (isReimbursementParticipant || canViewAllExpense);
|
||||
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
|
||||
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
|
||||
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
|
||||
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
||||
|
||||
const expenseTypes = expenseTypesQuery.data?.items ?? [];
|
||||
|
||||
@@ -449,6 +661,66 @@ export function ReimbursementDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvoiceDialogOpenChange = (open: boolean) => {
|
||||
setIsInvoiceDialogOpen(open);
|
||||
if (!open) {
|
||||
setInvoiceSearchInput("");
|
||||
setInvoiceSearch("");
|
||||
setSelectedInvoices({});
|
||||
setSelectedInvoiceFile(null);
|
||||
setInvoiceActionError("");
|
||||
setPendingOcrInvoiceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleInvoiceSelection = (invoiceId: string, checked: boolean) => {
|
||||
setSelectedInvoices((previous) => {
|
||||
if (!checked) {
|
||||
const next = { ...previous };
|
||||
delete next[invoiceId];
|
||||
return next;
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
[invoiceId]: previous[invoiceId] ?? "",
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const updateSelectedInvoiceItem = (invoiceId: string, linkedItemId: string) => {
|
||||
setSelectedInvoices((previous) => ({
|
||||
...previous,
|
||||
[invoiceId]: linkedItemId,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBindSelectedInvoices = () => {
|
||||
if (!selectedInvoiceEntries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
bindInvoicesMutation.mutate(
|
||||
selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({
|
||||
invoiceId,
|
||||
linkedItemId: linkedItemId || undefined,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
|
||||
if (!linkedItemId) {
|
||||
return "整张报销单";
|
||||
}
|
||||
|
||||
const linkedItem = detail.items.find((item) => item.id === linkedItemId);
|
||||
if (!linkedItem) {
|
||||
return linkedItemId;
|
||||
}
|
||||
|
||||
return `${linkedItem.expenseType?.name ?? linkedItem.expenseTypeId} · ${linkedItem.description ?? "未填写说明"}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageScaffold>
|
||||
<EntityOverviewHero
|
||||
@@ -464,9 +736,14 @@ export function ReimbursementDetailPage() {
|
||||
</>
|
||||
)}
|
||||
actions={(
|
||||
<Button asChild variant="secondary" data-testid="reimbursement-back">
|
||||
<Link to="/reimbursements">返回列表</Link>
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="secondary">
|
||||
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
|
||||
</Button>
|
||||
<Button asChild variant="secondary" data-testid="reimbursement-back">
|
||||
<Link to="/reimbursements">返回列表</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -718,15 +995,69 @@ export function ReimbursementDetailPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm" data-testid="reimbursement-invoices">
|
||||
<h3 className="text-base font-semibold text-zinc-900">关联票据</h3>
|
||||
<div className="space-y-4 text-sm" data-testid="reimbursement-invoices">
|
||||
<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">可在当前页面内直接绑定已有发票,或上传新发票后等待 OCR 完成。</p>
|
||||
</div>
|
||||
{canOpenInvoiceBindDialog ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => handleInvoiceDialogOpenChange(true)}
|
||||
>
|
||||
添加发票
|
||||
</Button>
|
||||
) : canEditItems ? (
|
||||
<p className="text-xs text-zinc-500">当前账号不可在此页绑定发票</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{invoiceActionError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{invoiceActionError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{(detail.invoices ?? []).length ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{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
|
||||
className="rounded-lg border border-zinc-200 px-4 py-3"
|
||||
key={item.id}
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-zinc-900">
|
||||
发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
|
||||
</div>
|
||||
<div className="text-zinc-600">
|
||||
{item.sellerName || "未识别卖方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
原始文件:{item.originalName || item.id}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
绑定范围:{resolveLinkedItemLabel(item.linkedItemId)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
|
||||
{item.status}
|
||||
</span>
|
||||
{canUnbindInvoices ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={unbindInvoiceMutation.isPending}
|
||||
onClick={() => unbindInvoiceMutation.mutate(item.id)}
|
||||
>
|
||||
解绑
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -891,6 +1222,149 @@ export function ReimbursementDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加发票</DialogTitle>
|
||||
<DialogDescription>选择当前报销人可绑定的可用发票,或上传新发票等待 OCR 完成后直接绑定。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<Input
|
||||
placeholder="搜索发票号、发票代码或卖方名称"
|
||||
value={invoiceSearchInput}
|
||||
onChange={(event) => setInvoiceSearchInput(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={bindableInvoicesQuery.isFetching}
|
||||
onClick={() => void bindableInvoicesQuery.refetch()}
|
||||
>
|
||||
{bindableInvoicesQuery.isFetching ? "刷新中..." : "刷新列表"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{invoiceActionError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{invoiceActionError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-[360px] space-y-3 overflow-y-auto pr-1">
|
||||
{bindableInvoicesQuery.isLoading ? (
|
||||
<p className="text-sm text-zinc-500">正在加载可绑定发票...</p>
|
||||
) : bindableInvoicesQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{buildActionErrorMessage(bindableInvoicesQuery.error, "加载可绑定发票失败")}
|
||||
</p>
|
||||
) : (bindableInvoicesQuery.data?.items ?? []).length ? (
|
||||
bindableInvoicesQuery.data?.items.map((item) => {
|
||||
const isSelected = Object.prototype.hasOwnProperty.call(selectedInvoices, item.id);
|
||||
return (
|
||||
<label
|
||||
key={item.id}
|
||||
className={`block rounded-lg border px-4 py-3 ${isSelected ? "border-primary-300 bg-primary-50/50" : "border-zinc-200"}`}
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 rounded border-zinc-300"
|
||||
checked={isSelected}
|
||||
onChange={(event) => toggleInvoiceSelection(item.id, event.target.checked)}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-zinc-900">
|
||||
发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
|
||||
</div>
|
||||
<div className="text-zinc-600">
|
||||
{item.sellerName || "未识别卖方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
原始文件:{item.originalName || item.id} · 上传时间 {formatDateTime(item.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[220px] md:max-w-[240px]">
|
||||
<div className="text-xs text-zinc-500">绑定到明细(可选)</div>
|
||||
<select
|
||||
className="select-std mt-1"
|
||||
value={selectedInvoices[item.id] ?? ""}
|
||||
disabled={!isSelected}
|
||||
onChange={(event) => updateSelectedInvoiceItem(item.id, event.target.value)}
|
||||
>
|
||||
<option value="">不绑定具体明细</option>
|
||||
{detail.items.map((detailItem) => (
|
||||
<option key={detailItem.id} value={detailItem.id}>
|
||||
{detailItem.expenseType?.name ?? detailItem.expenseTypeId} · {detailItem.description ?? "未填写说明"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-6 text-center text-sm text-zinc-500">
|
||||
当前没有可绑定发票,可直接在下方上传新发票。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="text-sm text-zinc-600">已选 {selectedInvoiceEntries.length} 张发票</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
|
||||
onClick={handleBindSelectedInvoices}
|
||||
>
|
||||
{bindInvoicesMutation.isPending ? "绑定中..." : "绑定选中发票"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-zinc-900">或者上传新发票</div>
|
||||
<p className="text-xs text-zinc-500">上传后会自动使用当前报销人的 owner 信息,并等待 OCR 识别完成。</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<label
|
||||
htmlFor="reimbursement-inline-invoice-upload"
|
||||
className="inline-flex cursor-pointer items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900"
|
||||
>
|
||||
{selectedInvoiceFile ? selectedInvoiceFile.name : "选择发票文件"}
|
||||
</label>
|
||||
<input
|
||||
id="reimbursement-inline-invoice-upload"
|
||||
className="hidden"
|
||||
type="file"
|
||||
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||
onChange={(event) => setSelectedInvoiceFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!selectedInvoiceFile || uploadInvoiceMutation.isPending}
|
||||
onClick={() => selectedInvoiceFile && uploadInvoiceMutation.mutate(selectedInvoiceFile)}
|
||||
>
|
||||
{uploadInvoiceMutation.isPending ? "上传中..." : "上传并等待 OCR"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pendingOcrInvoiceId ? (
|
||||
<p className="mt-3 text-xs text-zinc-500">
|
||||
发票 {pendingOcrInvoiceId.slice(0, 8)} 正在 OCR 识别中,系统会在可绑定后自动刷新列表。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isErpPreviewOpen} onOpenChange={setIsErpPreviewOpen}>
|
||||
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
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";
|
||||
|
||||
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}`;
|
||||
|
||||
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">{detail.formNumber ?? detail.id}</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">{detail.formNumber ?? detail.id}</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;
|
||||
在新工单中引用
屏蔽一个用户