1905 行
78 KiB
TypeScript
1905 行
78 KiB
TypeScript
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 { ChevronRight } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||
import {
|
||
archiveInvoice,
|
||
bindInvoice,
|
||
fetchBindableInvoices,
|
||
fetchInvoiceSignedUrl,
|
||
fetchInvoicePoolList,
|
||
unbindInvoice,
|
||
uploadInvoice,
|
||
type BindableInvoiceSummary,
|
||
type InvoicePoolListItem,
|
||
} from "../api/invoicePool";
|
||
import {
|
||
createReimbursementItem,
|
||
deleteReimbursementAttachment,
|
||
deleteReimbursement,
|
||
deleteReimbursementItem,
|
||
downloadReimbursementAttachment,
|
||
fetchReimbursementAttachmentPreviewUrl,
|
||
fetchReimbursementDetail,
|
||
fetchReimbursementMaterialPreviewBundle,
|
||
MAX_REIMBURSEMENT_ATTACHMENT_FILES,
|
||
precheckReimbursement,
|
||
submitReimbursement,
|
||
updateReimbursementItem,
|
||
uploadReimbursementAttachments,
|
||
linkReimbursementInvoiceItem,
|
||
type ReimbursementItem,
|
||
type ReimbursementStatus,
|
||
} from "../api/reimbursements";
|
||
import { remindWorkflowTask } from "../api/workflow";
|
||
import type { PrecheckResponse } from "../api/feeControl";
|
||
import { useAuth } from "../auth/AuthContext";
|
||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
||
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
|
||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
|
||
import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
|
||
import { isTravelReimbursement } from "../lib/reimbursementTravel";
|
||
import { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
|
||
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
|
||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||
import { MOBILE_STICKY_ACTION_PAGE_CLASS, MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar";
|
||
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 { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
|
||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||
import {
|
||
formatInvoiceDisplayTitle,
|
||
resolveInvoicePartyText,
|
||
resolveInvoiceNextStepText,
|
||
resolveInvoiceStatusLabel,
|
||
resolveInvoiceUserMessage,
|
||
} from "../lib/invoiceStatus";
|
||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
|
||
import { formatDisplayDateTime, formatMoney } from "../lib/displayFormat";
|
||
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||
import { TEST_IDS } from "../lib/testIds";
|
||
import { withUploadTimeout } from "../lib/uploadTimeout";
|
||
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||
import { cn } from "../lib/utils";
|
||
import { isWecomWebView } from "../lib/runtimeContext";
|
||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||
import { shouldShowOfficeElectronicsHint } from "./reimbursement-detail/ReimbursementItemEditor";
|
||
import {
|
||
focusFirstInvoiceGuardIssue,
|
||
invoiceGuardInvoiceMessages,
|
||
invoiceGuardIssueCount,
|
||
invoiceGuardItemMessages,
|
||
parseReimbursementInvoiceGuardIssues,
|
||
resolveInvoiceGuardIssuesAfterLink,
|
||
type ReimbursementInvoiceGuardIssues,
|
||
} from "../lib/reimbursementInvoiceGuard";
|
||
|
||
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 formatDate(value: string | null | undefined) {
|
||
return value || "未填写";
|
||
}
|
||
|
||
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 [invoiceGuardIssues, setInvoiceGuardIssues] = useState<ReimbursementInvoiceGuardIssues | null>(null);
|
||
const [invoiceItemSelections, setInvoiceItemSelections] = useState<Record<string, string>>({});
|
||
const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState<Record<string, string>>({});
|
||
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
||
const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState("");
|
||
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
|
||
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; name: string } | null>(null);
|
||
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 [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState("");
|
||
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
|
||
const [invoiceActionError, setInvoiceActionError] = useState("");
|
||
const [materialPreviewError, setMaterialPreviewError] = useState("");
|
||
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
|
||
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
|
||
const [pendingOcrLinkedItemIds, setPendingOcrLinkedItemIds] = useState<Record<string, 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 canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||
const canSubmitExpense = hasPermission(permissions, "expense.submit");
|
||
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||
const isParticipant = Boolean(detail && me?.id && [detail.ownerId, detail.submitterId].includes(me.id));
|
||
const canEdit = isEditableStatus(detail?.status);
|
||
const canMaintainReimbursement = Boolean(canEdit && isParticipant);
|
||
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(canMaintainReimbursement && canUploadInvoice && canViewOwnInvoice);
|
||
const canUnbindInvoices = canMaintainReimbursement;
|
||
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && isParticipant);
|
||
const canManageAttachments = canMaintainReimbursement;
|
||
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
||
|
||
const reimbursementPrecheckQuery = useQuery({
|
||
enabled: Boolean(reimbursementId && canSubmitExpense && canMaintainReimbursement),
|
||
queryKey: ["reimbursements", reimbursementId, "precheck"],
|
||
queryFn: () => precheckReimbursement(reimbursementId),
|
||
});
|
||
|
||
function handleInvoicePdfSelection(files: FileList | null) {
|
||
const { accepted, rejected } = splitPdfFiles(files);
|
||
setSelectedInvoiceFiles(accepted);
|
||
const message = rejectedPdfMessage(rejected);
|
||
setInvoiceActionError(message);
|
||
if (message) {
|
||
toast.warning(message);
|
||
}
|
||
}
|
||
|
||
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,
|
||
buyerName: null,
|
||
documentCategory: item.documentCategory,
|
||
documentSubtype: item.documentSubtype,
|
||
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("");
|
||
setInvoiceGuardIssues(null);
|
||
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 (data) => {
|
||
await invalidateDetail();
|
||
setSubmitConfirmPrecheck(null);
|
||
setActionError("");
|
||
setInvoiceGuardIssues(null);
|
||
toast.success(
|
||
data.invoiceAmountManualReviewRequired
|
||
? "已提交,票面金额将由财务核对"
|
||
: "已提交审批",
|
||
);
|
||
},
|
||
onError: (error) => {
|
||
const message = resolveErrorMessage(error);
|
||
const issues = parseReimbursementInvoiceGuardIssues(error);
|
||
setActionError(message);
|
||
setInvoiceGuardIssues(issues);
|
||
if (issues) focusFirstInvoiceGuardIssue();
|
||
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))),
|
||
);
|
||
setPendingOcrLinkedItemIds((previous) =>
|
||
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
|
||
);
|
||
if (succeededIds.length) setInvoiceGuardIssues(null);
|
||
|
||
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,
|
||
linkedItemId: pendingOcrLinkedItemIds[invoiceId] || undefined,
|
||
})),
|
||
);
|
||
},
|
||
[bindInvoicesMutation, pendingOcrLinkedItemIds],
|
||
);
|
||
|
||
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("");
|
||
const unavailableIds = new Set(items.map((item) => item.id));
|
||
setPendingOcrLinkedItemIds((previous) =>
|
||
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !unavailableIds.has(invoiceId))),
|
||
);
|
||
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("");
|
||
setInvoiceGuardIssues(null);
|
||
toast.success("发票已移除");
|
||
},
|
||
onError: (error) => {
|
||
const message = resolveErrorMessage(error);
|
||
setInvoiceActionError(message);
|
||
toast.error(message);
|
||
},
|
||
});
|
||
|
||
const linkInvoiceItemMutation = useMutation({
|
||
mutationFn: ({ invoiceId, linkedItemId }: { invoiceId: string; linkedItemId: string }) =>
|
||
linkReimbursementInvoiceItem(reimbursementId, invoiceId, linkedItemId),
|
||
onSuccess: async (_result, variables) => {
|
||
await invalidateDetail();
|
||
setInvoiceGuardIssues((previous) => resolveInvoiceGuardIssuesAfterLink(
|
||
previous,
|
||
variables.invoiceId,
|
||
variables.linkedItemId,
|
||
));
|
||
setInvoiceItemSelections((previous) => {
|
||
const next = { ...previous };
|
||
delete next[variables.invoiceId];
|
||
return next;
|
||
});
|
||
setInvoiceItemLinkErrors((previous) => {
|
||
const next = { ...previous };
|
||
delete next[variables.invoiceId];
|
||
return next;
|
||
});
|
||
toast.success("发票已关联到报销明细");
|
||
},
|
||
onError: (error, variables) => {
|
||
const message = resolveErrorMessage(error, "关联明细失败,请刷新后重试");
|
||
setInvoiceItemLinkErrors((previous) => ({ ...previous, [variables.invoiceId]: 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,
|
||
...(detail.invoiceGuardEnabled ? { linkedItemId: invoiceUploadLinkedItemId } : {}),
|
||
});
|
||
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])));
|
||
if (detail?.invoiceGuardEnabled && invoiceUploadLinkedItemId) {
|
||
setPendingOcrLinkedItemIds((previous) => ({
|
||
...previous,
|
||
...Object.fromEntries(result.pendingIds.map((invoiceId) => [invoiceId, invoiceUploadLinkedItemId])),
|
||
}));
|
||
}
|
||
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("请选择附件材料");
|
||
}
|
||
if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) {
|
||
throw new Error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`);
|
||
}
|
||
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, reason }: { attachmentId: string; reason: string }) =>
|
||
deleteReimbursementAttachment(reimbursementId, attachmentId, reason),
|
||
onSuccess: async () => {
|
||
await invalidateDetail();
|
||
setDeleteAttachmentTarget(null);
|
||
toast.success("附件材料已删除");
|
||
},
|
||
onError: (error) => {
|
||
toast.error(resolveErrorMessage(error));
|
||
},
|
||
});
|
||
|
||
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
|
||
|
||
const openInvoiceMutation = useMutation({
|
||
mutationFn: async (invoiceId: string) => {
|
||
const result = await fetchInvoiceSignedUrl(invoiceId);
|
||
const openedInWecom = await previewFileInWecom({
|
||
url: result.signedUrl,
|
||
name: result.originalName,
|
||
size: result.fileSize,
|
||
});
|
||
if (!openedInWecom) {
|
||
openFileInBrowser(result.signedUrl);
|
||
}
|
||
},
|
||
onError: (error) => {
|
||
toast.error(resolveErrorMessage(error));
|
||
},
|
||
});
|
||
|
||
const openAttachmentMutation = useMutation({
|
||
mutationFn: async ({ attachment, reason }: {
|
||
attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields;
|
||
reason?: string;
|
||
}) => {
|
||
if (isWecomWebView()) {
|
||
const result = await fetchReimbursementAttachmentPreviewUrl(reimbursementId, attachment.id, reason);
|
||
await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
|
||
return null;
|
||
}
|
||
return downloadReimbursementAttachment(reimbursementId, attachment.id, reason);
|
||
},
|
||
onSuccess: (result) => {
|
||
if (!result) return;
|
||
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));
|
||
},
|
||
});
|
||
|
||
async function handleOpenAttachment(attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields) {
|
||
const reason = await requestDownloadReason(attachment);
|
||
if (reason === null) {
|
||
return;
|
||
}
|
||
openAttachmentMutation.mutate({ attachment, reason });
|
||
}
|
||
|
||
const materialPreviewMutation = useMutation({
|
||
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
|
||
onSuccess: async (bundle) => {
|
||
setMaterialPreviewError("");
|
||
if (isWecomWebView()) {
|
||
try {
|
||
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
|
||
return;
|
||
} catch {
|
||
toast.error("企业微信材料预览未打开,已切换到浏览器预览");
|
||
}
|
||
}
|
||
openFileInBrowser(bundle.signedUrl);
|
||
},
|
||
onError: (error) => {
|
||
const message = resolveErrorMessage(error) || "连续预览生成失败,可逐份打开原件";
|
||
setMaterialPreviewError(message);
|
||
toast.error("连续预览生成失败,可逐份打开原件");
|
||
},
|
||
});
|
||
|
||
async function handleOpenAllMaterials() {
|
||
const governedAttachment = (detail?.attachments ?? []).find(attachmentRequiresDownloadReason);
|
||
const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined;
|
||
if (reason === null) return;
|
||
materialPreviewMutation.mutate(reason);
|
||
}
|
||
|
||
const handleSubmit = async () => {
|
||
if (!detail) {
|
||
return;
|
||
}
|
||
if (!detail.items.length) {
|
||
const message = "请先添加至少 1 条报销明细";
|
||
setActionError(message);
|
||
toast.error(message);
|
||
return;
|
||
}
|
||
if (!hasCompleteTravelSummary(detail)) {
|
||
const message = "请先补全出差事由、地点和起止日期";
|
||
setActionError(message);
|
||
toast.error(message);
|
||
return;
|
||
}
|
||
const requiresAnyInvoice = detail.items.some((item) => item.expenseType?.requiresInvoice !== false);
|
||
if (requiresAnyInvoice && !detail.invoices.length) {
|
||
const message = "请先添加至少 1 张发票";
|
||
setActionError(message);
|
||
toast.error(message);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setActionError("");
|
||
setInvoiceGuardIssues(null);
|
||
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([]);
|
||
setInvoiceUploadLinkedItemId("");
|
||
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-700">缺少报销单 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-700">
|
||
{resolveErrorMessage(detailQuery.error)}
|
||
</div>
|
||
<Button asChild variant="secondary">
|
||
<Link to="/mobile/reimbursements">返回报销</Link>
|
||
</Button>
|
||
</PageScaffold>
|
||
);
|
||
}
|
||
|
||
const displayCode = resolveReimbursementDisplayCode(detail);
|
||
const showSubmitAction = Boolean(canMaintainReimbursement && canSubmitExpense);
|
||
const showStickyActions = showSubmitAction;
|
||
const showSecondaryActions = Boolean(actionError || canDeleteDraft);
|
||
|
||
return (
|
||
<PageScaffold
|
||
className={cn(
|
||
"space-y-4",
|
||
showStickyActions ? MOBILE_STICKY_ACTION_PAGE_CLASS : "pb-6",
|
||
)}
|
||
data-testid="mobile-reimbursement-detail"
|
||
>
|
||
{downloadReasonDialog}
|
||
<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 mobile-focus-card mobile-page-stack" data-testid="mobile-reimbursement-detail-focus">
|
||
<div className="mobile-section-header mobile-section-header--top">
|
||
<div>
|
||
<h2 className="mobile-section-title">报销概况</h2>
|
||
{!isParticipant ? (
|
||
<p className="mt-1 text-xs text-zinc-500">
|
||
只读查看
|
||
{detail.status === "draft"
|
||
? " · 等待报销人完善"
|
||
: detail.status === "rejected"
|
||
? " · 等待报销人修改后重提"
|
||
: ""}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div data-testid="mobile-reimbursement-summary">
|
||
<div className="text-xs text-zinc-500">合计金额</div>
|
||
<div className="mt-1 text-3xl font-semibold text-zinc-900">{formatMoney(detail.totalAmount)}</div>
|
||
</div>
|
||
<div className="mt-4 grid grid-cols-2 gap-3 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 truncate font-semibold text-zinc-900">{formatDate(detail.reimbursementDate)}</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">{detail.owner?.name ?? "-"}</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">{detail.department?.name ?? "-"}</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">{detail.submitter?.name ?? "-"}</div>
|
||
</div>
|
||
</div>
|
||
<ReimbursementHeaderEditor
|
||
reimbursementId={reimbursementId}
|
||
reimbursementDate={detail.reimbursementDate}
|
||
description={detail.description}
|
||
editable={Boolean(canEdit && isParticipant)}
|
||
mobile
|
||
showDescription={!isTravelReimbursement(detail)}
|
||
/>
|
||
<ReimbursementTravelSummary
|
||
detail={detail}
|
||
reimbursementId={reimbursementId}
|
||
editable={Boolean(canEdit && isParticipant)}
|
||
mobile
|
||
embedded
|
||
/>
|
||
{detail.project || detail.linkedTravelRequest ? (
|
||
<div className="mt-4 border-t border-zinc-200 pt-4 text-sm text-zinc-600">
|
||
{detail.project ? <p>项目:{detail.project.projectNo} · {detail.project.name}</p> : null}
|
||
{detail.linkedTravelRequest ? (
|
||
<Link className="mt-2 inline-flex min-h-11 items-center text-primary hover:underline" to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}>
|
||
差旅申请:{detail.linkedTravelRequest.requestNo ?? "未编号"}
|
||
</Link>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{detail.riskFlags.length ? (
|
||
<div className="mt-4 border-t border-zinc-200 pt-4">
|
||
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
|
||
<section className="mobile-card" data-testid="mobile-reimbursement-processing-section">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<h2 className="text-base font-semibold text-zinc-900">处理进度</h2>
|
||
</div>
|
||
{canRemindPendingWorkflow && pendingTaskId ? (
|
||
<Button
|
||
size="sm"
|
||
type="button"
|
||
variant="secondary"
|
||
disabled={remindMutation.isPending}
|
||
onClick={() => remindMutation.mutate(pendingTaskId)}
|
||
>
|
||
{remindMutation.isPending ? "催办中..." : "催办"}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
<div className="mt-3">
|
||
<ProcessingProgress
|
||
progress={detail.processingProgress ?? { stages: [] }}
|
||
ariaLabel="报销处理进度"
|
||
testId="mobile-reimbursement-processing-progress"
|
||
targetLinkLabel="查看所在付款批次"
|
||
mobile
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
{invoiceGuardIssues ? (
|
||
<section
|
||
className="mobile-card border-red-300 bg-red-50"
|
||
data-testid="mobile-reimbursement-invoice-guard-summary"
|
||
role="alert"
|
||
aria-live="assertive"
|
||
>
|
||
<h2 className="text-base font-semibold text-red-800">票据检查未通过</h2>
|
||
<p className="mt-2 text-sm leading-6 text-red-700">
|
||
共 {invoiceGuardIssueCount(invoiceGuardIssues)} 项需要处理,请按下方明细和发票提示修改。
|
||
</p>
|
||
</section>
|
||
) : null}
|
||
|
||
<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>
|
||
{canMaintainReimbursement ? (
|
||
<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-700">{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>
|
||
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.description ? (
|
||
<span className="text-xs text-zinc-500">
|
||
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.description}
|
||
</span>
|
||
) : null}
|
||
</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>
|
||
{shouldShowOfficeElectronicsHint(
|
||
expenseTypes.find((type) => type.id === itemForm.expenseTypeId),
|
||
itemForm.amount,
|
||
) ? (
|
||
<p className="col-span-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
|
||
) : null}
|
||
</div>
|
||
<div className="mobile-two-action-grid">
|
||
<Button
|
||
type="button"
|
||
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || 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={cn(
|
||
"mobile-list-card",
|
||
invoiceGuardItemMessages(invoiceGuardIssues, item.id).length && "border-red-300 bg-red-50/60",
|
||
)}
|
||
data-reimbursement-guard-issue={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? "true" : undefined}
|
||
tabIndex={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? -1 : undefined}
|
||
>
|
||
<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">{formatMoney(item.amount)}</div>
|
||
</div>
|
||
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
|
||
{shouldShowOfficeElectronicsHint(item.expenseType, item.amount) ? (
|
||
<p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
|
||
) : null}
|
||
{invoiceGuardItemMessages(invoiceGuardIssues, item.id).map((message) => (
|
||
<p
|
||
key={message}
|
||
className="mt-2 rounded-md border border-red-200 bg-white px-3 py-2 text-sm leading-5 text-red-700"
|
||
role="alert"
|
||
>
|
||
{message}
|
||
</p>
|
||
))}
|
||
{canMaintainReimbursement ? (
|
||
<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>
|
||
<div className="mt-3 flex items-center justify-between rounded-lg bg-zinc-50 px-3 py-3 text-sm font-medium text-zinc-900">
|
||
<span>共 {detail.items.length} 条明细</span>
|
||
<span>报销合计 {formatMoney(detail.totalAmount)}</span>
|
||
</div>
|
||
<ReimbursementOperationalHints
|
||
precheck={reimbursementPrecheckQuery.data}
|
||
items={detail.items}
|
||
canAccessAssets={canAccessAssets}
|
||
mobile
|
||
/>
|
||
</section>
|
||
|
||
<section className="mobile-card" data-testid="mobile-reimbursement-materials">
|
||
<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">
|
||
发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{detail.invoiceAmountManualReviewRequired ? (
|
||
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-800">
|
||
本单包含票面金额未自动读取的发票,系统未自动比较相关明细金额;财务审批时需查看发票原件核对。
|
||
</p>
|
||
) : null}
|
||
|
||
{detail.invoices.length || (detail.attachments ?? []).length ? (
|
||
<div className="mt-3 space-y-2">
|
||
<p className="text-xs leading-5 text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</p>
|
||
<Button
|
||
className="min-h-12 w-full"
|
||
data-testid="mobile-reimbursement-materials-open-all"
|
||
type="button"
|
||
disabled={materialPreviewMutation.isPending}
|
||
onClick={() => void handleOpenAllMaterials()}
|
||
>
|
||
{materialPreviewMutation.isPending ? "正在准备材料预览…" : "查看材料合并预览"}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
|
||
{materialPreviewError ? (
|
||
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||
{materialPreviewError},可在下方逐份打开原件。
|
||
</p>
|
||
) : null}
|
||
|
||
{canOpenInvoiceDialog || canManageAttachments ? (
|
||
<div className="mobile-two-action-grid mt-3">
|
||
{canOpenInvoiceDialog ? (
|
||
<Button
|
||
data-testid={TEST_IDS.reimbursement.invoiceAdd}
|
||
size="sm"
|
||
variant="secondary"
|
||
type="button"
|
||
onClick={() => closeInvoiceDialog(true)}
|
||
>
|
||
添加发票
|
||
</Button>
|
||
) : null}
|
||
{canManageAttachments ? (
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
type="button"
|
||
onClick={() => attachmentInputRef.current?.click()}
|
||
>
|
||
添加附件
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{invoiceActionError ? (
|
||
<p className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||
{invoiceActionError}
|
||
</p>
|
||
) : null}
|
||
|
||
{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) => {
|
||
const files = Array.from(event.target.files ?? []);
|
||
if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) {
|
||
setSelectedAttachmentFiles([]);
|
||
event.target.value = "";
|
||
toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`);
|
||
return;
|
||
}
|
||
setSelectedAttachmentFiles(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.invoices.length || (detail.attachments ?? []).length ? (
|
||
<div className="mt-3 space-y-2">
|
||
<div data-testid={TEST_IDS.reimbursement.invoices} className="space-y-2">
|
||
{detail.invoices.map((invoice) => {
|
||
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
|
||
? "已关联"
|
||
: resolveInvoiceNextStepText(invoice);
|
||
const guardMessages = invoiceGuardInvoiceMessages(
|
||
invoiceGuardIssues,
|
||
invoice.id,
|
||
invoice.linkedItemId,
|
||
);
|
||
return (
|
||
<article
|
||
key={invoice.id}
|
||
className={cn(
|
||
"mobile-list-card",
|
||
guardMessages.length && "border-red-300 bg-red-50/60",
|
||
)}
|
||
data-reimbursement-guard-issue={guardMessages.length ? "true" : undefined}
|
||
tabIndex={guardMessages.length ? -1 : undefined}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="flex min-h-11 w-full items-start gap-3 rounded-lg text-left transition-colors hover:bg-zinc-50 active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
|
||
disabled={openInvoiceMutation.isPending}
|
||
aria-label={`查看发票原件:${formatInvoiceDisplayTitle(invoice)}`}
|
||
onClick={() => openInvoiceMutation.mutate(invoice.id)}
|
||
>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="inline-flex rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">发票</span>
|
||
<span className="mt-2 block truncate text-sm font-semibold text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</span>
|
||
<span className="mt-1 block text-xs text-zinc-500">
|
||
{[resolveInvoicePartyText(invoice), invoice.invoiceDate ? formatDate(invoice.invoiceDate) : "", formatMoney(invoice.totalAmount)].filter(Boolean).join(" · ")}
|
||
</span>
|
||
<span className="mt-1 block text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</span>
|
||
{invoiceHint ? <span className="mt-1 block text-xs text-zinc-500">{invoiceHint}</span> : null}
|
||
</span>
|
||
<span className="shrink-0 text-right">
|
||
<span className="inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
|
||
{resolveInvoiceStatusLabel(invoice.status)}
|
||
</span>
|
||
<ChevronRight aria-hidden="true" className="ml-auto mt-2 size-4 text-zinc-400" />
|
||
</span>
|
||
</button>
|
||
{guardMessages.map((message) => (
|
||
<p
|
||
key={message}
|
||
className="mt-3 rounded-md border border-red-200 bg-white px-3 py-2 text-sm leading-5 text-red-700"
|
||
role="alert"
|
||
>
|
||
{message}
|
||
</p>
|
||
))}
|
||
{invoice.amountEvidenceStatus === "manual_review" ? (
|
||
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-5 text-amber-800">
|
||
票面金额未自动识别,需由财务查看原件核对。
|
||
</p>
|
||
) : null}
|
||
{invoice.amountEvidenceStatus === "ocr_failed" ? (
|
||
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm leading-5 text-red-700">
|
||
该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。
|
||
</p>
|
||
) : null}
|
||
{canMaintainReimbursement && !invoice.linkedItemId ? (
|
||
<div className="mt-3 space-y-2">
|
||
<label className="block text-sm font-medium text-zinc-700" htmlFor={`mobile-invoice-item-link-${invoice.id}`}>
|
||
选择报销明细
|
||
</label>
|
||
<select
|
||
id={`mobile-invoice-item-link-${invoice.id}`}
|
||
className="select-std min-h-11 w-full"
|
||
value={invoiceItemSelections[invoice.id] ?? ""}
|
||
onChange={(event) => setInvoiceItemSelections((previous) => ({
|
||
...previous,
|
||
[invoice.id]: event.target.value,
|
||
}))}
|
||
>
|
||
<option value="">请选择明细</option>
|
||
{detail.items.map((item) => (
|
||
<option key={item.id} value={item.id}>
|
||
{item.expenseType?.name ?? "费用"} · {item.description ?? formatMoney(item.amount)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<Button
|
||
className="min-h-11 w-full"
|
||
type="button"
|
||
disabled={!invoiceItemSelections[invoice.id] || linkInvoiceItemMutation.isPending}
|
||
onClick={() => linkInvoiceItemMutation.mutate({
|
||
invoiceId: invoice.id,
|
||
linkedItemId: invoiceItemSelections[invoice.id],
|
||
})}
|
||
>
|
||
{linkInvoiceItemMutation.isPending ? "关联中..." : "确认关联"}
|
||
</Button>
|
||
{invoiceItemLinkErrors[invoice.id] ? (
|
||
<p className="text-sm leading-5 text-red-700" role="alert">
|
||
{invoiceItemLinkErrors[invoice.id]}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{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 data-testid="mobile-reimbursement-attachments" className="space-y-2">
|
||
{(detail.attachments ?? []).map((attachment) => (
|
||
<article key={attachment.id} className="mobile-list-card">
|
||
<button
|
||
type="button"
|
||
className="flex min-h-11 w-full items-center gap-3 rounded-lg text-left transition-colors hover:bg-zinc-50 active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
|
||
disabled={openAttachmentMutation.isPending}
|
||
aria-label={`查看补充附件:${attachment.originalName}`}
|
||
onClick={() => void handleOpenAttachment(attachment)}
|
||
>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-600">补充附件</span>
|
||
<span className="mt-2 block truncate text-sm font-semibold text-zinc-900">{attachment.originalName}</span>
|
||
<span className="mt-1 block text-xs text-zinc-500">
|
||
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
|
||
</span>
|
||
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
|
||
</span>
|
||
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
|
||
</button>
|
||
{canManageAttachments ? (
|
||
<Button
|
||
className="mt-3 w-full"
|
||
size="sm"
|
||
variant="secondary"
|
||
type="button"
|
||
disabled={deleteAttachmentMutation.isPending}
|
||
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, name: attachment.originalName })}
|
||
>
|
||
删除
|
||
</Button>
|
||
) : null}
|
||
</article>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
|
||
暂无报销材料
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{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-700"
|
||
role="alert"
|
||
aria-live="assertive"
|
||
>
|
||
{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}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{showStickyActions ? (
|
||
<MobileStickyActionBar testId="mobile-reimbursement-sticky-actions">
|
||
{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}
|
||
</MobileStickyActionBar>
|
||
) : 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-700">
|
||
{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={PDF_FILE_ACCEPT}
|
||
multiple
|
||
onChange={(event) => {
|
||
handleInvoicePdfSelection(event.target.files);
|
||
event.target.value = "";
|
||
}}
|
||
/>
|
||
|
||
{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}
|
||
|
||
{detail.invoiceGuardEnabled ? (
|
||
<select
|
||
aria-label="新上传发票关联明细"
|
||
className="select-std w-full"
|
||
value={invoiceUploadLinkedItemId}
|
||
onChange={(event) => setInvoiceUploadLinkedItemId(event.target.value)}
|
||
>
|
||
<option value="">请选择新上传发票对应的费用明细</option>
|
||
{detail.items.map((detailItem) => (
|
||
<option key={detailItem.id} value={detailItem.id}>
|
||
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : null}
|
||
|
||
<Button
|
||
className="w-full"
|
||
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
|
||
type="button"
|
||
disabled={!selectedInvoiceFiles.length || (detail.invoiceGuardEnabled && !invoiceUploadLinkedItemId) || 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-700">
|
||
{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">
|
||
{[resolveInvoicePartyText(invoice), invoice.invoiceDate ? formatDate(invoice.invoiceDate) : "", formatMoney(invoice.totalAmount)].filter(Boolean).join(" · ")}
|
||
</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">
|
||
{detail.invoiceGuardEnabled ? "请选择该发票对应的费用明细" : "添加后自动生成或匹配报销明细"}
|
||
</div>
|
||
) : null}
|
||
{isAvailable && checked && detail.invoiceGuardEnabled ? (
|
||
<select
|
||
aria-label="关联费用明细"
|
||
className="select-std mt-3 w-full"
|
||
value={selectedInvoices[invoice.id] ?? ""}
|
||
onChange={(event) => setSelectedInvoices((previous) => ({ ...previous, [invoice.id]: event.target.value }))}
|
||
>
|
||
<option value="">请选择费用明细</option>
|
||
{detail.items.map((detailItem) => (
|
||
<option key={detailItem.id} value={detailItem.id}>
|
||
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : 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 || (detail.invoiceGuardEnabled && selectedInvoiceEntries.some(([, linkedItemId]) => !linkedItemId)) || bindInvoicesMutation.isPending}
|
||
onClick={() =>
|
||
bindInvoicesMutation.mutate(
|
||
selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({
|
||
invoiceId,
|
||
linkedItemId: 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()}
|
||
/>
|
||
|
||
<AttachmentDeleteConfirmDialog
|
||
open={Boolean(deleteAttachmentTarget)}
|
||
targetName={deleteAttachmentTarget?.name}
|
||
pending={deleteAttachmentMutation.isPending}
|
||
onOpenChange={(open) => {
|
||
if (!open) setDeleteAttachmentTarget(null);
|
||
}}
|
||
onConfirm={(reason) => {
|
||
if (deleteAttachmentTarget) {
|
||
deleteAttachmentMutation.mutate({ attachmentId: deleteAttachmentTarget.id, reason });
|
||
}
|
||
}}
|
||
/>
|
||
</PageScaffold>
|
||
);
|
||
}
|
||
|
||
export default MobileReimbursementDetailPage;
|