1493 行
57 KiB
TypeScript
1493 行
57 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 { 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;
|