R58 A1-A6:报销详情页内嵌发票绑定与单据打印

这个提交包含在:
2026-04-11 14:57:15 +08:00
父节点 88c987ae8f
当前提交 ba48e7d1d4
共修改 14 个文件,包含 1667 行新增和 12 行删除
+486 -12
查看文件
@@ -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>