文件
hyb-oa/client/src/pages/MobileReimbursementDetailPage.tsx
T
2026-05-14 00:52:59 +08:00

1493 行
57 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchExpenseTypes } from "../api/expenseTypes";
import {
archiveInvoice,
bindInvoice,
fetchBindableInvoices,
fetchInvoicePoolList,
unbindInvoice,
uploadInvoice,
type BindableInvoiceSummary,
type InvoicePoolListItem,
} from "../api/invoicePool";
import {
createReimbursementItem,
deleteReimbursementAttachment,
deleteReimbursement,
deleteReimbursementItem,
downloadReimbursementAttachment,
fetchReimbursementDetail,
precheckReimbursement,
submitReimbursement,
updateReimbursementItem,
uploadReimbursementAttachments,
type ReimbursementItem,
type ReimbursementStatus,
} from "../api/reimbursements";
import { remindWorkflowTask } from "../api/workflow";
import type { PrecheckResponse } from "../api/feeControl";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { MobileIcon } from "../components/mobile/MobileIcon";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Textarea } from "../components/ui/textarea";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import {
formatInvoiceDisplayTitle,
resolveInvoiceNextStepText,
resolveInvoiceStatusLabel,
resolveInvoiceUserMessage,
} from "../lib/invoiceStatus";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { TEST_IDS } from "../lib/testIds";
import { withUploadTimeout } from "../lib/uploadTimeout";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { cn } from "../lib/utils";
type ItemFormState = {
mode: "create" | "edit";
itemId?: string;
expenseTypeId: string;
expenseDate: string;
amount: string;
description: string;
};
type SelectedInvoiceMap = Record<string, string>;
type InvoicePanel = "upload" | "library";
type MobileInvoiceLibraryItem = BindableInvoiceSummary & {
status: "available" | "ocr_failed";
unavailableMessage?: string | null;
};
const STATUS_LABEL: Record<ReimbursementStatus, string> = {
draft: "草稿",
reviewing: "审批中",
approved: "已通过",
paid: "已支付",
rejected: "已驳回",
cancelled: "已取消",
};
const STATUS_TONE: Record<ReimbursementStatus, string> = {
draft: "bg-zinc-100 text-zinc-700",
reviewing: "bg-amber-50 text-amber-700",
approved: "bg-emerald-50 text-emerald-700",
paid: "bg-blue-50 text-blue-700",
rejected: "bg-red-50 text-red-700",
cancelled: "bg-zinc-100 text-zinc-500",
};
function reimbursementFallback(status: ReimbursementStatus) {
return `/mobile/reimbursements?status=${encodeURIComponent(status)}`;
}
function todayDate() {
return new Date().toISOString().slice(0, 10);
}
function formatCurrency(value: string | null | undefined) {
const numberValue = Number(value ?? 0);
if (!Number.isFinite(numberValue)) {
return "¥0.00";
}
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
}).format(numberValue);
}
function formatDate(value: string | null | undefined) {
return value || "未填写";
}
function formatDateTime(value: string | null | undefined) {
if (!value) {
return "-";
}
return value.slice(0, 19).replace("T", " ");
}
function workflowActionLabel(action: string) {
const labels: Record<string, string> = {
start: "提交",
approve: "通过",
reject: "驳回",
return: "退回",
transfer: "转审",
withdraw: "撤回",
remind: "催办",
overdue_remind: "超时提醒",
};
return labels[action] ?? action;
}
function matchInvoiceSearch(
invoice: {
invoiceCode?: string | null;
invoiceNumber?: string | null;
sellerName?: string | null;
originalName?: string | null;
},
searchLower: string,
) {
if (!searchLower) {
return true;
}
return [invoice.invoiceCode, invoice.invoiceNumber, invoice.sellerName, invoice.originalName]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(searchLower));
}
function isEditableStatus(status: ReimbursementStatus | undefined) {
return status === "draft" || status === "rejected";
}
function itemLabel(item: ReimbursementItem) {
const typeName = item.expenseType?.name ?? "费用";
const desc = item.description?.trim();
return desc ? `${typeName} · ${desc}` : typeName;
}
export function MobileReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { me, permissions, accessToken } = useAuth();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
const [itemError, setItemError] = useState("");
const [actionError, setActionError] = useState("");
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState("");
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
const [invoicePanel, setInvoicePanel] = useState<InvoicePanel>("upload");
const [invoiceSearchInput, setInvoiceSearchInput] = useState("");
const [invoiceSearch, setInvoiceSearch] = useState("");
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
const detailQuery = useQuery({
enabled: Boolean(reimbursementId),
queryKey: ["reimbursements", reimbursementId],
queryFn: () => fetchReimbursementDetail(reimbursementId),
});
const expenseTypesQuery = useQuery({
queryKey: ["expenseTypes", "enabled"],
queryFn: () => fetchExpenseTypes({ enabled: true }),
});
const detail = detailQuery.data;
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
const canSubmitExpense = hasPermission(permissions, "expense.submit");
const isParticipant = Boolean(detail && me?.id && [detail.ownerId, detail.submitterId].includes(me.id));
const canEdit = isEditableStatus(detail?.status);
const pendingTaskId = detail?.pendingWorkflow?.task?.id ?? null;
const canRemindPendingWorkflow = Boolean(
detail?.status === "reviewing" &&
pendingTaskId &&
isParticipant &&
detail?.pendingWorkflow?.task?.assignee_id !== me?.id,
);
const canOpenInvoiceDialog = Boolean(canEdit && isParticipant && canUploadInvoice && canViewOwnInvoice);
const canUnbindInvoices = Boolean(canEdit && (isParticipant || canViewAllExpense));
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && (isParticipant || canViewAllExpense));
const canManageAttachments = Boolean(canEdit && (isParticipant || canViewAllExpense));
const selectedInvoiceEntries = Object.entries(selectedInvoices);
const bindableInvoicesQuery = useQuery({
enabled: Boolean(reimbursementId && isInvoiceDialogOpen && canOpenInvoiceDialog),
queryKey: ["invoice-pool", "bindable", reimbursementId, invoiceSearch],
queryFn: () =>
fetchBindableInvoices({
reimbursementId,
search: invoiceSearch || undefined,
limit: 20,
}),
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
});
const failedInvoicesQuery = useQuery({
enabled: Boolean(reimbursementId && isInvoiceDialogOpen && canOpenInvoiceDialog),
queryKey: ["invoice-pool", "failed-own", reimbursementId],
queryFn: () =>
fetchInvoicePoolList({
status: "ocr_failed",
ownerScope: "own",
pageSize: 20,
}),
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
});
const invoiceStatusTone = pendingOcrInvoiceIds.length ? "pending" : "success";
const invoiceSearchLower = invoiceSearch.toLowerCase();
const bindableLibraryInvoices = useMemo<MobileInvoiceLibraryItem[]>(
() =>
(bindableInvoicesQuery.data?.items ?? []).map((item) => ({
...item,
status: "available",
})),
[bindableInvoicesQuery.data?.items],
);
const failedLibraryInvoices = useMemo<MobileInvoiceLibraryItem[]>(
() =>
(failedInvoicesQuery.data?.items ?? [])
.filter((item: InvoicePoolListItem) => !item.linkedReimbursementId && !item.linkedItemId && !item.linkedTravelOrderId)
.filter((item: InvoicePoolListItem) => matchInvoiceSearch(item, invoiceSearchLower))
.map((item: InvoicePoolListItem) => ({
id: item.id,
invoiceCode: item.invoiceCode,
invoiceNumber: item.invoiceNumber,
invoiceDate: item.invoiceDate,
sellerName: null,
totalAmount: item.totalAmount,
originalName: item.originalName,
createdAt: item.createdAt,
status: "ocr_failed",
unavailableMessage: resolveInvoiceUserMessage(item),
})),
[failedInvoicesQuery.data?.items, invoiceSearchLower],
);
const invoiceLibraryItems = useMemo(
() => [...bindableLibraryInvoices, ...failedLibraryInvoices],
[bindableLibraryInvoices, failedLibraryInvoices],
);
const invoiceLibraryLoading = bindableInvoicesQuery.isLoading || failedInvoicesQuery.isLoading;
const invoiceLibraryFetching = bindableInvoicesQuery.isFetching || failedInvoicesQuery.isFetching;
const invoiceLibraryError = bindableInvoicesQuery.error ?? failedInvoicesQuery.error ?? null;
useEffect(() => {
const timerId = window.setTimeout(() => {
setInvoiceSearch(invoiceSearchInput.trim());
}, 300);
return () => window.clearTimeout(timerId);
}, [invoiceSearchInput]);
useEffect(() => {
const candidateIds = new Set(bindableLibraryInvoices.map((item) => item.id));
setSelectedInvoices((previous) =>
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => candidateIds.has(invoiceId))),
);
}, [bindableLibraryInvoices]);
const invalidateDetail = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }),
]);
};
const startCreateItem = () => {
setItemForm({
mode: "create",
expenseTypeId: expenseTypesQuery.data?.items?.[0]?.id ?? "",
expenseDate: detail?.reimbursementDate ?? todayDate(),
amount: "",
description: "",
});
setItemError("");
};
const startEditItem = (item: ReimbursementItem) => {
setItemForm({
mode: "edit",
itemId: item.id,
expenseTypeId: item.expenseTypeId,
expenseDate: item.expenseDate ?? detail?.reimbursementDate ?? todayDate(),
amount: item.amount,
description: item.description ?? "",
});
setItemError("");
};
const itemSaveMutation = useMutation({
mutationFn: (form: ItemFormState) => {
if (!reimbursementId) {
throw new Error("缺少报销单 ID");
}
const payload = {
expenseTypeId: form.expenseTypeId,
expenseDate: form.expenseDate,
amount: form.amount,
description: form.description.trim() || undefined,
};
if (form.mode === "edit" && form.itemId) {
return updateReimbursementItem(reimbursementId, form.itemId, payload);
}
return createReimbursementItem(reimbursementId, payload);
},
onSuccess: async () => {
await invalidateDetail();
setItemForm(null);
setItemError("");
toast.success("明细已保存");
},
onError: (error) => {
const message = resolveErrorMessage(error);
setItemError(message);
toast.error(message);
},
});
const itemDeleteMutation = useMutation({
mutationFn: (itemId: string) => deleteReimbursementItem(reimbursementId, itemId),
onSuccess: async () => {
await invalidateDetail();
setConfirmDeleteItemId("");
toast.success("明细已删除");
},
onError: (error) => {
const message = resolveErrorMessage(error);
setItemError(message);
toast.error(message);
},
});
const deleteReimbursementMutation = useMutation({
mutationFn: () => deleteReimbursement(reimbursementId),
onSuccess: async () => {
await invalidateDetail();
toast.success("草稿已删除");
navigate("/mobile/reimbursements");
},
onError: (error) => {
const message = resolveErrorMessage(error);
setActionError(message);
toast.error(message);
},
});
const submitMutation = useMutation({
mutationFn: () => submitReimbursement(reimbursementId),
onSuccess: async () => {
await invalidateDetail();
setSubmitConfirmPrecheck(null);
setActionError("");
toast.success("已提交审批");
},
onError: (error) => {
const message = resolveErrorMessage(error);
setActionError(message);
toast.error(message);
},
});
const remindMutation = useMutation({
mutationFn: (taskId: string) => remindWorkflowTask(taskId),
onSuccess: async () => {
await Promise.all([
invalidateDetail(),
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow"] }),
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
]);
toast.success("已催办当前审批人");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const bindInvoicesMutation = useMutation({
mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => {
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: resolveErrorMessage(error) });
}
}
return results;
},
onSuccess: async (results) => {
await invalidateDetail();
await bindableInvoicesQuery.refetch();
const failed = 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 (!failed.length) {
setInvoiceActionError("");
setInvoiceActionMessage(`已添加 ${succeededIds.length} 张发票`);
toast.success(`已添加 ${succeededIds.length} 张发票`);
return;
}
const message = failed
.map((item) => {
const invoice = invoiceLibraryItems.find((candidate) => candidate.id === item.invoiceId);
return `${formatInvoiceDisplayTitle(invoice)}:${item.message ?? "添加失败"}`;
})
.join(";");
setInvoiceActionError(message);
toast.error("部分发票添加失败");
},
onError: (error) => {
const message = resolveErrorMessage(error);
setInvoiceActionError(message);
toast.error(message);
},
});
const handleTrackedInvoicesAvailable = useCallback(
(invoiceIds: string[]) => {
if (!invoiceIds.length) {
return;
}
bindInvoicesMutation.mutate(invoiceIds.map((invoiceId) => ({ invoiceId })));
},
[bindInvoicesMutation],
);
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => {
const message = items
.map((item) => `${formatInvoiceDisplayTitle(item)}:${resolveInvoiceUserMessage(item)}`)
.join(";");
setInvoiceActionError(message);
setInvoiceActionMessage("");
toast.error("部分发票需要处理");
}, []);
const invoiceOcrTracker = useInvoiceOcrTracker({
accessToken,
enabled: Boolean(reimbursementId),
pendingInvoiceIds: pendingOcrInvoiceIds,
setPendingInvoiceIds: setPendingOcrInvoiceIds,
onAvailable: handleTrackedInvoicesAvailable,
onUnavailable: handleTrackedInvoicesUnavailable,
});
const invoiceStatusMessage = invoiceOcrTracker.statusMessage || invoiceActionMessage;
const unbindInvoiceMutation = useMutation({
mutationFn: (invoiceId: string) => unbindInvoice(invoiceId),
onSuccess: async () => {
await invalidateDetail();
setInvoiceActionError("");
toast.success("发票已移除");
},
onError: (error) => {
const message = resolveErrorMessage(error);
setInvoiceActionError(message);
toast.error(message);
},
});
const deleteAvailableInvoiceMutation = useMutation({
mutationFn: (invoiceId: string) => archiveInvoice(invoiceId),
onSuccess: async (_result, invoiceId) => {
await invalidateDetail();
await Promise.all([bindableInvoicesQuery.refetch(), failedInvoicesQuery.refetch()]);
setSelectedInvoices((previous) => {
const next = { ...previous };
delete next[invoiceId];
return next;
});
setConfirmDeleteInvoiceId("");
setInvoiceActionError("");
setInvoiceActionMessage("发票已删除");
toast.success("发票已删除");
},
onError: (error) => {
const message = resolveErrorMessage(error);
setInvoiceActionError(message);
toast.error(message);
},
});
const uploadInvoiceMutation = useMutation({
mutationFn: async (files: File[]) => {
if (!detail?.ownerId) {
throw new Error("缺少报销人信息");
}
if (!files.length) {
throw new Error("请选择发票文件");
}
const failures: Array<{ file: File; message: string }> = [];
const pendingIds: string[] = [];
let uploadedCount = 0;
let boundCount = 0;
setInvoiceUploadProgress({ completed: 0, total: files.length });
for (const [index, file] of files.entries()) {
try {
const uploadResult = await withUploadTimeout((signal) =>
uploadInvoice(file, "vat", detail.ownerId, { signal }),
);
uploadedCount += 1;
if (uploadResult.uploadAction === "blocked" || !uploadResult.invoicePoolId) {
failures.push({
file,
message: resolveInvoiceUserMessage(uploadResult),
});
} else if (uploadResult.status === "available") {
try {
await bindInvoice(uploadResult.invoicePoolId, { reimbursementId });
boundCount += 1;
} catch (error) {
failures.push({ file, message: `上传成功,添加失败:${resolveErrorMessage(error)}` });
}
} else if (uploadResult.status === "pending_ocr") {
pendingIds.push(uploadResult.invoicePoolId);
} else if (uploadResult.status === "ocr_failed") {
failures.push({ file, message: resolveInvoiceUserMessage(uploadResult) });
} else {
failures.push({ file, message: resolveInvoiceUserMessage(uploadResult) });
}
} catch (error) {
failures.push({ file, message: resolveErrorMessage(error) });
}
setInvoiceUploadProgress({ completed: index + 1, total: files.length });
}
return { uploadedCount, boundCount, pendingIds, failures };
},
onSuccess: async (result) => {
await invalidateDetail();
await bindableInvoicesQuery.refetch();
setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds])));
setSelectedInvoiceFiles(result.failures.map((item) => item.file));
if (!result.failures.length) {
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
const messages: string[] = [];
if (result.boundCount) {
messages.push(`${result.boundCount} 张已添加`);
}
if (result.pendingIds.length) {
messages.push(`已上传 ${result.pendingIds.length} 张,正在识别`);
}
if (result.failures.length) {
setInvoiceActionError(result.failures.map((item) => `${item.file.name}:${item.message}`).join(";"));
} else {
setInvoiceActionError("");
}
setInvoiceActionMessage(messages.length ? messages.join(",") : `已上传 ${result.uploadedCount} 张`);
if (result.failures.length) {
toast.error(`上传完成,${result.failures.length} 项需处理`);
return;
}
if (result.pendingIds.length) {
return;
}
toast.success(messages.length ? messages.join(",") : "上传完成");
},
onError: (error) => {
const message = resolveErrorMessage(error);
setInvoiceActionError(message);
toast.error(message);
},
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
});
const uploadAttachmentsMutation = useMutation({
mutationFn: async (files: File[]) => {
if (!files.length) {
throw new Error("请选择附件材料");
}
return uploadReimbursementAttachments(reimbursementId, files);
},
onSuccess: async () => {
await invalidateDetail();
setSelectedAttachmentFiles([]);
if (attachmentInputRef.current) {
attachmentInputRef.current.value = "";
}
toast.success("附件材料已上传");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const deleteAttachmentMutation = useMutation({
mutationFn: (attachmentId: string) => deleteReimbursementAttachment(reimbursementId, attachmentId),
onSuccess: async () => {
await invalidateDetail();
toast.success("附件材料已删除");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const openAttachmentMutation = useMutation({
mutationFn: (attachmentId: string) => downloadReimbursementAttachment(reimbursementId, attachmentId),
onSuccess: (result) => {
const url = URL.createObjectURL(result.blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const handleSubmit = async () => {
if (!detail) {
return;
}
if (!detail.items.length) {
const message = "请先添加至少 1 条报销明细";
setActionError(message);
toast.error(message);
return;
}
if (!detail.invoices.length) {
const message = "请先添加至少 1 张发票";
setActionError(message);
toast.error(message);
return;
}
try {
setActionError("");
const precheck = await precheckReimbursement(reimbursementId);
const message = buildPrecheckDialogMessage(precheck);
if (precheck.decision === "block") {
setActionError(message);
toast.error("提交校验未通过");
return;
}
if (precheck.decision === "warn" || precheck.decision === "manual_review") {
setSubmitConfirmPrecheck(precheck);
return;
}
submitMutation.mutate();
} catch (error) {
const message = resolveErrorMessage(error);
setActionError(message);
toast.error(message);
}
};
const closeInvoiceDialog = (open: boolean) => {
setIsInvoiceDialogOpen(open);
if (open) {
setInvoicePanel("upload");
return;
}
setInvoiceSearchInput("");
setInvoiceSearch("");
setSelectedInvoices({});
setSelectedInvoiceFiles([]);
setInvoiceActionError("");
setInvoiceActionMessage("");
setConfirmDeleteInvoiceId("");
setPendingOcrInvoiceIds([]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const toggleInvoiceSelection = (invoice: BindableInvoiceSummary, checked: boolean) => {
setSelectedInvoices((previous) => {
if (!checked) {
const next = { ...previous };
delete next[invoice.id];
return next;
}
return {
...previous,
[invoice.id]: previous[invoice.id] ?? "",
};
});
};
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
if (!linkedItemId) {
return "自动生成或匹配明细";
}
const item = detail?.items.find((candidate) => candidate.id === linkedItemId);
return item ? itemLabel(item) : "明细";
};
const expenseTypes = expenseTypesQuery.data?.items ?? [];
if (!reimbursementId) {
return (
<PageScaffold>
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-600">缺少报销单 ID。</div>
</PageScaffold>
);
}
if (detailQuery.isLoading) {
return (
<PageScaffold className="space-y-4">
<div className="h-24 animate-pulse rounded-lg bg-zinc-100" />
<div className="h-44 animate-pulse rounded-lg bg-zinc-100" />
<div className="h-44 animate-pulse rounded-lg bg-zinc-100" />
</PageScaffold>
);
}
if (detailQuery.isError || !detail) {
return (
<PageScaffold className="space-y-4">
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-600">
{resolveErrorMessage(detailQuery.error)}
</div>
<Button asChild variant="secondary">
<Link to="/mobile/reimbursements">返回报销</Link>
</Button>
</PageScaffold>
);
}
const displayCode = resolveReimbursementDisplayCode(detail);
const showSubmitAction = Boolean(canEdit && canSubmitExpense);
const showStickyActions = Boolean(showSubmitAction || canOpenInvoiceDialog);
const showSecondaryActions = Boolean(actionError || canDeleteDraft || !canEdit);
return (
<PageScaffold
className={cn(
"space-y-4",
showStickyActions ? "mobile-fixed-action-page" : "",
showStickyActions
? "pb-[calc(var(--mobile-sticky-action-height,78px)+env(safe-area-inset-bottom,0px)+1.25rem)]"
: "pb-6",
)}
data-testid="mobile-reimbursement-detail"
>
<MobileDetailHeader
title={displayCode}
subtitle="报销详情"
fallbackTo={reimbursementFallback(detail.status)}
status={
<span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>
{STATUS_LABEL[detail.status]}
</span>
}
testId="mobile-reimbursement-detail-header"
/>
<section className="mobile-card">
<div className="text-xs text-zinc-500">合计金额</div>
<div className="mt-1 text-3xl font-semibold text-zinc-900">{formatCurrency(detail.totalAmount)}</div>
<div className="mobile-metric-grid mt-4 text-sm">
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">明细</div>
<div className="mt-1 font-semibold text-zinc-900">{detail.items.length}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">发票</div>
<div className="mt-1 font-semibold text-zinc-900">{detail.invoices.length}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">日期</div>
<div className="mt-1 truncate font-semibold text-zinc-900">{formatDate(detail.reimbursementDate)}</div>
</div>
</div>
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
</section>
<section className="mobile-card" data-testid="mobile-reimbursement-items">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
{canEdit ? (
<Button size="sm" variant="secondary" type="button" onClick={startCreateItem}>
新增
</Button>
) : null}
</div>
{itemError ? (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{itemError}</div>
) : null}
{itemForm ? (
<div className="mt-3 space-y-3 rounded-lg border border-primary-100 bg-primary-50/40 p-3">
<div className="mobile-info-grid">
<label className="col-span-2 block space-y-1 text-sm text-zinc-700">
<span>费用类型</span>
<select
className="select-std"
disabled={!expenseTypes.length || itemSaveMutation.isPending}
value={itemForm.expenseTypeId}
onChange={(event) => setItemForm((current) => current ? { ...current, expenseTypeId: event.target.value } : current)}
>
{!expenseTypes.length ? <option value="">暂无可用类型</option> : null}
{expenseTypes.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
</label>
<label className="block space-y-1 text-sm text-zinc-700">
<span>日期</span>
<Input
disabled={itemSaveMutation.isPending}
type="date"
value={itemForm.expenseDate}
onChange={(event) => setItemForm((current) => current ? { ...current, expenseDate: event.target.value } : current)}
/>
</label>
<label className="block space-y-1 text-sm text-zinc-700">
<span>金额</span>
<Input
disabled={itemSaveMutation.isPending}
inputMode="decimal"
placeholder="0.00"
value={itemForm.amount}
onChange={(event) => setItemForm((current) => current ? { ...current, amount: event.target.value } : current)}
/>
</label>
<label className="col-span-2 block space-y-1 text-sm text-zinc-700">
<span>说明</span>
<Textarea
disabled={itemSaveMutation.isPending}
rows={2}
value={itemForm.description}
onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)}
/>
</label>
</div>
<div className="mobile-two-action-grid">
<Button
type="button"
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !itemForm.amount.trim() || itemSaveMutation.isPending}
onClick={() => itemSaveMutation.mutate(itemForm)}
>
{itemSaveMutation.isPending ? "保存中..." : "保存"}
</Button>
<Button type="button" variant="secondary" disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)}>
取消
</Button>
</div>
</div>
) : null}
<div className="mt-3 space-y-2">
{detail.items.length ? (
detail.items.map((item) => (
<article key={item.id} className="mobile-list-card">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{item.expenseType?.name ?? "费用"}</div>
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.expenseDate)}</div>
</div>
<div className="shrink-0 text-base font-semibold text-zinc-900">{formatCurrency(item.amount)}</div>
</div>
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
{canEdit ? (
<div className="mobile-two-action-grid mt-3">
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
编辑
</Button>
<Button
size="sm"
variant="secondary"
type="button"
disabled={itemDeleteMutation.isPending}
onClick={() => setConfirmDeleteItemId(item.id)}
>
删除
</Button>
</div>
) : null}
</article>
))
) : (
<div className="rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无明细
</div>
)}
</div>
</section>
<section className="mobile-card" data-testid={TEST_IDS.reimbursement.invoices}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">发票</h2>
{canOpenInvoiceDialog ? (
<Button
data-testid={TEST_IDS.reimbursement.invoiceAdd}
size="sm"
variant="secondary"
type="button"
onClick={() => closeInvoiceDialog(true)}
>
添加
</Button>
) : null}
</div>
{detail.invoices.length ? (
<div className="mt-3 space-y-2">
{detail.invoices.map((invoice) => {
const invoiceHint = resolveInvoiceNextStepText(invoice);
return (
<article key={invoice.id} className="mobile-list-card">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</div>
<div className="mt-1 text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)}
</div>
<div className="mt-1 text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</div>
{invoiceHint ? <div className="mt-1 text-xs text-zinc-500">{invoiceHint}</div> : null}
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-semibold text-zinc-900">{formatCurrency(invoice.totalAmount)}</div>
<span className="mt-1 inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
{resolveInvoiceStatusLabel(invoice.status)}
</span>
</div>
</div>
{canUnbindInvoices ? (
<Button
className="mt-3 w-full"
size="sm"
variant="secondary"
type="button"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
>
移除
</Button>
) : null}
</article>
);
})}
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无发票
</div>
)}
</section>
<section className="mobile-card" data-testid="mobile-reimbursement-attachments">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">附件材料</h2>
<p className="mt-1 text-xs text-zinc-500">合同、订单、付款截图或情况说明</p>
</div>
{canManageAttachments ? (
<Button
size="sm"
variant="secondary"
type="button"
onClick={() => attachmentInputRef.current?.click()}
>
选择
</Button>
) : null}
</div>
{canManageAttachments ? (
<div className="mt-3 space-y-3">
<input
ref={attachmentInputRef}
className="sr-only"
type="file"
accept="application/pdf,image/jpeg,image/png,image/webp"
multiple
onChange={(event) => setSelectedAttachmentFiles(Array.from(event.target.files ?? []))}
/>
{selectedAttachmentFiles.length ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{selectedAttachmentFiles.slice(0, 4).map((file) => (
<div key={`${file.name}-${file.size}`} className="truncate">
{file.name} · {Math.ceil(file.size / 1024)} KB
</div>
))}
{selectedAttachmentFiles.length > 4 ? <div>另有 {selectedAttachmentFiles.length - 4} 个文件</div> : null}
</div>
) : null}
<Button
className="w-full"
type="button"
variant="secondary"
disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending}
onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)}
>
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
</Button>
</div>
) : null}
{(detail.attachments ?? []).length ? (
<div className="mt-3 space-y-2">
{(detail.attachments ?? []).map((attachment) => (
<article key={attachment.id} className="mobile-list-card">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{attachment.originalName}</div>
<div className="mt-1 text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
</div>
</div>
<div className="mobile-two-action-grid mt-3">
<Button
size="sm"
variant="secondary"
type="button"
disabled={openAttachmentMutation.isPending}
onClick={() => openAttachmentMutation.mutate(attachment.id)}
>
查看
</Button>
{canManageAttachments ? (
<Button
size="sm"
variant="secondary"
type="button"
disabled={deleteAttachmentMutation.isPending}
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
>
删除
</Button>
) : null}
</div>
</article>
))}
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无附件材料
</div>
)}
</section>
{detail.status === "reviewing" || detail.approvalHistory.length ? (
<section className="mobile-card" data-testid="mobile-reimbursement-approval-history">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">审批</h2>
<p className="mt-1 text-xs text-zinc-500">
当前节点:{detail.pendingWorkflow?.task?.step_name ?? "-"}
</p>
</div>
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
size="sm"
type="button"
variant="secondary"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
>
{remindMutation.isPending ? "催办中..." : "催办"}
</Button>
) : null}
</div>
{detail.approvalHistory.length ? (
<div className="mt-3 space-y-2">
{detail.approvalHistory.map((item) => (
<article key={item.id} className="mobile-list-card">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-zinc-900">{workflowActionLabel(item.action)}</div>
<div className="shrink-0 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.stepName ?? "-"} · {item.actorName || "系统"}
</div>
{item.comment ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.comment}</p> : null}
</article>
))}
</div>
) : (
<p className="mt-3 text-sm text-zinc-500">暂无审批记录。</p>
)}
</section>
) : null}
{showSecondaryActions ? (
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
{actionError ? (
<div className="mt-3 whitespace-pre-wrap rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{actionError}
</div>
) : null}
<div className="mt-3 grid gap-2">
{canDeleteDraft ? (
<Button
className="w-full"
disabled={deleteReimbursementMutation.isPending}
type="button"
variant="destructive"
onClick={() => setConfirmDeleteReimbursement(true)}
>
删除草稿
</Button>
) : null}
{!canEdit ? <div className="text-sm text-zinc-500">当前状态不可编辑。</div> : null}
</div>
</section>
) : null}
{showStickyActions ? (
<div
className="mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur"
data-testid="mobile-reimbursement-sticky-actions"
>
<div className="mobile-sticky-action-grid">
{canOpenInvoiceDialog ? (
<Button
className="min-h-11 min-w-0 px-2 text-sm whitespace-nowrap"
data-testid="mobile-reimbursement-sticky-invoice-add"
variant={showSubmitAction ? "secondary" : "default"}
type="button"
onClick={() => closeInvoiceDialog(true)}
>
添加发票
</Button>
) : null}
{showSubmitAction ? (
<Button
className="min-h-11 min-w-0 px-2 text-sm whitespace-nowrap"
data-testid={TEST_IDS.reimbursement.submit}
disabled={submitMutation.isPending}
type="button"
onClick={() => void handleSubmit()}
>
{submitMutation.isPending ? "提交中..." : "提交审批"}
</Button>
) : null}
</div>
</div>
) : null}
<Dialog open={isInvoiceDialogOpen} onOpenChange={closeInvoiceDialog}>
<DialogContent
className="overflow-y-auto p-4"
data-testid={TEST_IDS.reimbursement.invoiceDialog}
onOpenAutoFocus={(event) => event.preventDefault()}
style={{ maxHeight: "min(86vh, calc(var(--mobile-viewport-height, 100vh) - 3rem))" }}
>
<DialogHeader className="text-left">
<DialogTitle>添加发票</DialogTitle>
<DialogDescription>可一次上传多张,识别完成后会自动添加。</DialogDescription>
</DialogHeader>
<div className="mobile-two-action-grid mt-4 rounded-lg bg-zinc-100 p-1">
<button
type="button"
className={cn("rounded-md px-3 py-2 text-sm font-medium", invoicePanel === "upload" ? "bg-white text-zinc-900 shadow-sm" : "text-zinc-500")}
onClick={() => setInvoicePanel("upload")}
>
上传
</button>
<button
type="button"
className={cn("rounded-md px-3 py-2 text-sm font-medium", invoicePanel === "library" ? "bg-white text-zinc-900 shadow-sm" : "text-zinc-500")}
onClick={() => setInvoicePanel("library")}
>
已有发票
</button>
</div>
{invoiceActionError ? (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{invoiceActionError}
</div>
) : null}
{invoiceStatusMessage ? (
<div
className={cn(
"mt-3 rounded-lg border px-3 py-2 text-sm",
invoiceStatusTone === "pending"
? "border-amber-200 bg-amber-50 text-amber-700"
: "border-emerald-200 bg-emerald-50 text-emerald-700",
)}
data-testid={invoiceStatusTone === "pending" ? TEST_IDS.reimbursement.invoicePendingHint : undefined}
>
{invoiceStatusMessage}
</div>
) : null}
{invoicePanel === "upload" ? (
<div className="mt-4 space-y-3">
<div className="grid gap-2">
<label
htmlFor="mobile-reimbursement-invoice-upload-pdf"
className="flex min-h-28 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-center"
>
<MobileIcon name="picture_as_pdf" className="size-7 text-zinc-700" />
<span className="mt-2 text-sm font-semibold text-zinc-900">选择 PDF 发票</span>
<span className="mt-1 text-xs text-zinc-500">仅支持 PDF,减少拍照模糊和错传</span>
</label>
</div>
<input
ref={fileInputRef}
id="mobile-reimbursement-invoice-upload-pdf"
className="sr-only"
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
type="file"
accept="application/pdf"
multiple
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
/>
{selectedInvoiceFiles.length ? (
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-500">
{selectedInvoiceFiles.slice(0, 5).map((file) => (
<div key={`${file.name}-${file.size}`} className="truncate">
{file.name} · {Math.ceil(file.size / 1024)} KB
</div>
))}
{selectedInvoiceFiles.length > 5 ? <div>另有 {selectedInvoiceFiles.length - 5} 个文件</div> : null}
</div>
) : null}
<Button
className="w-full"
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
type="button"
disabled={!selectedInvoiceFiles.length || uploadInvoiceMutation.isPending}
onClick={() => uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
>
{uploadInvoiceMutation.isPending
? `上传中 ${invoiceUploadProgress.completed}/${invoiceUploadProgress.total}`
: "上传发票"}
</Button>
</div>
) : (
<div className="mt-4 space-y-3">
<div className="flex gap-2">
<Input
data-testid="mobile-reimbursement-invoice-search"
inputMode="search"
placeholder="搜索发票"
value={invoiceSearchInput}
onChange={(event) => setInvoiceSearchInput(event.target.value)}
/>
<Button
type="button"
variant="secondary"
disabled={invoiceLibraryFetching}
onClick={() => {
void bindableInvoicesQuery.refetch();
void failedInvoicesQuery.refetch();
}}
>
刷新
</Button>
</div>
<div
className="space-y-2 overflow-y-auto pr-1"
style={{ maxHeight: "min(44vh, calc(var(--mobile-viewport-height, 100vh) * 0.44))" }}
>
{invoiceLibraryLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
{invoiceLibraryError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(invoiceLibraryError)}
</div>
) : null}
{!invoiceLibraryLoading && !invoiceLibraryError && !invoiceLibraryItems.length ? (
<div className="rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无可添加发票
</div>
) : null}
{invoiceLibraryItems.map((invoice) => {
const isAvailable = invoice.status === "available";
const isFailed = invoice.status === "ocr_failed";
const checked = Object.prototype.hasOwnProperty.call(selectedInvoices, invoice.id);
return (
<article
key={invoice.id}
className={cn(
"block rounded-lg border px-3 py-3",
checked ? "border-primary-300 bg-primary-50" : "border-zinc-200 bg-white",
isFailed ? "border-red-200 bg-red-50/40" : "",
)}
>
<div className="flex gap-3">
{isAvailable ? (
<input
className="mt-1 h-4 w-4 rounded border-zinc-300"
type="checkbox"
checked={checked}
onChange={(event) => toggleInvoiceSelection(invoice, event.target.checked)}
/>
) : (
<span className="mt-0.5 inline-flex shrink-0 rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-700">
{resolveInvoiceStatusLabel(invoice.status)}
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</div>
{isAvailable ? (
<span className="shrink-0 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
{resolveInvoiceStatusLabel(invoice.status)}
</span>
) : null}
</div>
<div className="mt-1 text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)} · {formatCurrency(invoice.totalAmount)}
</div>
</div>
</div>
{isFailed ? (
<div className="mt-3 rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">
{resolveInvoiceUserMessage(invoice)}
</div>
) : null}
{isAvailable && checked ? (
<div className="mt-3 rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700">
添加后自动生成或匹配报销明细
</div>
) : null}
<Button
className="mt-3 w-full"
size="sm"
type="button"
variant="secondary"
disabled={deleteAvailableInvoiceMutation.isPending || bindInvoicesMutation.isPending}
onClick={() => setConfirmDeleteInvoiceId(invoice.id)}
>
删除
</Button>
</article>
);
})}
</div>
<Button
className="w-full"
type="button"
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
onClick={() =>
bindInvoicesMutation.mutate(
selectedInvoiceEntries.map(([invoiceId]) => ({
invoiceId,
linkedItemId: undefined,
})),
)
}
>
{bindInvoicesMutation.isPending ? "添加中..." : `添加选中发票 ${selectedInvoiceEntries.length}`}
</Button>
</div>
)}
</DialogContent>
</Dialog>
<Dialog
open={Boolean(submitConfirmPrecheck)}
onOpenChange={(open) => {
if (!open) {
setSubmitConfirmPrecheck(null);
}
}}
>
<DialogContent className="max-h-[86dvh] overflow-y-auto p-4">
<DialogHeader className="text-left">
<DialogTitle>确认提交</DialogTitle>
<DialogDescription>以下校验项会进入审批记录。</DialogDescription>
</DialogHeader>
{submitConfirmPrecheck ? (
<div className="mt-4 space-y-3">
<div className="whitespace-pre-wrap rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm text-zinc-700">
{buildPrecheckDialogMessage(submitConfirmPrecheck)}
</div>
<div className="mobile-two-action-grid">
<Button type="button" variant="secondary" onClick={() => setSubmitConfirmPrecheck(null)}>
取消
</Button>
<Button type="button" disabled={submitMutation.isPending} onClick={() => submitMutation.mutate()}>
{submitMutation.isPending ? "提交中..." : "确认提交"}
</Button>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
<ConfirmDialog
open={Boolean(confirmDeleteItemId)}
onOpenChange={(open) => {
if (!open) setConfirmDeleteItemId("");
}}
title="删除该明细?"
description="删除后会重新计算报销金额。"
variant="destructive"
confirmLabel="删除"
onConfirm={() => itemDeleteMutation.mutate(confirmDeleteItemId)}
/>
<ConfirmDialog
open={Boolean(confirmDeleteInvoiceId)}
onOpenChange={(open) => {
if (!open) setConfirmDeleteInvoiceId("");
}}
title="删除发票?"
description="删除后将从可添加发票列表中移除。"
variant="destructive"
confirmLabel="删除"
onConfirm={() => {
if (confirmDeleteInvoiceId) {
deleteAvailableInvoiceMutation.mutate(confirmDeleteInvoiceId);
}
}}
/>
<ConfirmDialog
open={confirmDeleteReimbursement}
onOpenChange={setConfirmDeleteReimbursement}
title="删除草稿?"
description="删除后无法恢复,已添加发票会回到可添加状态。"
variant="destructive"
confirmLabel="删除草稿"
onConfirm={() => deleteReimbursementMutation.mutate()}
/>
</PageScaffold>
);
}
export default MobileReimbursementDetailPage;