1063 行
41 KiB
TypeScript
1063 行
41 KiB
TypeScript
import { useEffect, 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 {
|
||
bindInvoice,
|
||
fetchBindableInvoices,
|
||
unbindInvoice,
|
||
uploadInvoice,
|
||
type BindableInvoiceSummary,
|
||
} from "../api/invoicePool";
|
||
import {
|
||
createReimbursementItem,
|
||
deleteReimbursement,
|
||
deleteReimbursementItem,
|
||
fetchReimbursementDetail,
|
||
precheckReimbursement,
|
||
submitReimbursement,
|
||
updateReimbursementItem,
|
||
type ReimbursementItem,
|
||
type ReimbursementStatus,
|
||
} from "../api/reimbursements";
|
||
import type { PrecheckResponse } from "../api/feeControl";
|
||
import { useAuth } from "../auth/AuthContext";
|
||
import { hasPermission } from "../auth/permissions";
|
||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||
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 { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||
import { TEST_IDS } from "../lib/testIds";
|
||
import { withUploadTimeout } from "../lib/uploadTimeout";
|
||
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";
|
||
|
||
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",
|
||
};
|
||
|
||
const INVOICE_STATUS_LABEL: Record<string, string> = {
|
||
pending_ocr: "识别中",
|
||
ocr_failed: "识别失败",
|
||
available: "可用",
|
||
bound: "已绑定",
|
||
archived: "已归档",
|
||
};
|
||
|
||
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 formatInvoiceTitle(invoice: {
|
||
invoiceCode?: string | null;
|
||
invoiceNumber?: string | null;
|
||
sellerName?: string | null;
|
||
originalName?: string | null;
|
||
}) {
|
||
if (invoice.invoiceCode || invoice.invoiceNumber) {
|
||
return `${invoice.invoiceCode || "-"}-${invoice.invoiceNumber || "-"}`;
|
||
}
|
||
return invoice.sellerName || invoice.originalName || "未识别票据";
|
||
}
|
||
|
||
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 } = useAuth();
|
||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||
|
||
const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
|
||
const [itemError, setItemError] = useState("");
|
||
const [actionError, setActionError] = useState("");
|
||
const [confirmDeleteItemId, setConfirmDeleteItemId] = 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 [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 canOpenInvoiceDialog = Boolean(canEdit && isParticipant && canUploadInvoice && canViewOwnInvoice);
|
||
const canUnbindInvoices = Boolean(canEdit && (isParticipant || canViewAllExpense));
|
||
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && (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,
|
||
});
|
||
|
||
useEffect(() => {
|
||
const timerId = window.setTimeout(() => {
|
||
setInvoiceSearch(invoiceSearchInput.trim());
|
||
}, 300);
|
||
return () => window.clearTimeout(timerId);
|
||
}, [invoiceSearchInput]);
|
||
|
||
useEffect(() => {
|
||
const candidateIds = new Set((bindableInvoicesQuery.data?.items ?? []).map((item) => item.id));
|
||
setSelectedInvoices((previous) =>
|
||
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => candidateIds.has(invoiceId))),
|
||
);
|
||
}, [bindableInvoicesQuery.data?.items]);
|
||
|
||
const 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 resolveDefaultLinkedItemId = (invoice?: BindableInvoiceSummary | null) => {
|
||
if (!detail?.items.length) {
|
||
return "";
|
||
}
|
||
if (detail.items.length === 1) {
|
||
return detail.items[0].id;
|
||
}
|
||
|
||
const invoiceAmount = Number(invoice?.totalAmount ?? NaN);
|
||
if (!Number.isFinite(invoiceAmount)) {
|
||
return "";
|
||
}
|
||
|
||
const matchedItems = detail.items.filter((item) => Number(item.amount) === invoiceAmount);
|
||
return matchedItems.length === 1 ? matchedItems[0].id : "";
|
||
};
|
||
|
||
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 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) => `${item.invoiceId.slice(0, 8)}:${item.message ?? "绑定失败"}`).join(";");
|
||
setInvoiceActionError(message);
|
||
toast.error("部分发票绑定失败");
|
||
},
|
||
onError: (error) => {
|
||
const message = resolveErrorMessage(error);
|
||
setInvoiceActionError(message);
|
||
toast.error(message);
|
||
},
|
||
});
|
||
|
||
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 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.status === "available") {
|
||
const linkedItemId = resolveDefaultLinkedItemId(null);
|
||
try {
|
||
await bindInvoice(uploadResult.invoicePoolId, {
|
||
reimbursementId,
|
||
...(linkedItemId ? { linkedItemId } : {}),
|
||
});
|
||
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: "OCR 识别失败,请到发票池补录后再绑定" });
|
||
}
|
||
} 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(result.pendingIds);
|
||
setSelectedInvoiceFiles(result.failures.map((item) => item.file));
|
||
if (!result.failures.length && 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;
|
||
}
|
||
toast.success(messages.length ? messages.join(",") : "上传完成");
|
||
},
|
||
onError: (error) => {
|
||
const message = resolveErrorMessage(error);
|
||
setInvoiceActionError(message);
|
||
toast.error(message);
|
||
},
|
||
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
|
||
});
|
||
|
||
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("");
|
||
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] ?? resolveDefaultLinkedItemId(invoice),
|
||
};
|
||
});
|
||
};
|
||
|
||
const updateSelectedInvoiceItem = (invoiceId: string, linkedItemId: string) => {
|
||
setSelectedInvoices((previous) => ({
|
||
...previous,
|
||
[invoiceId]: linkedItemId,
|
||
}));
|
||
};
|
||
|
||
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);
|
||
|
||
return (
|
||
<PageScaffold className="space-y-4 pb-4" data-testid="mobile-reimbursement-detail">
|
||
<header className="flex items-center gap-3" data-testid="mobile-reimbursement-detail">
|
||
<Button asChild size="sm" variant="secondary">
|
||
<Link to="/mobile/reimbursements">返回</Link>
|
||
</Button>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-base font-semibold text-zinc-900">{displayCode}</div>
|
||
<div className="text-xs text-zinc-500">报销详情</div>
|
||
</div>
|
||
<span className={cn("shrink-0 rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>
|
||
{STATUS_LABEL[detail.status]}
|
||
</span>
|
||
</header>
|
||
|
||
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
|
||
<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="mt-4 grid grid-cols-3 gap-2 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="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" 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="grid grid-cols-2 gap-3">
|
||
<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="grid grid-cols-2 gap-2">
|
||
<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="rounded-lg border border-zinc-200 px-3 py-3">
|
||
<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="mt-3 grid grid-cols-2 gap-2">
|
||
<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="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" 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) => (
|
||
<article key={invoice.id} className="rounded-lg border border-zinc-200 px-3 py-3">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceTitle(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>
|
||
</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">
|
||
{INVOICE_STATUS_LABEL[invoice.status] ?? 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="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" 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">
|
||
{canEdit && canSubmitExpense ? (
|
||
<Button
|
||
className="w-full"
|
||
data-testid={TEST_IDS.reimbursement.submit}
|
||
disabled={submitMutation.isPending}
|
||
type="button"
|
||
onClick={() => void handleSubmit()}
|
||
>
|
||
{submitMutation.isPending ? "提交中..." : "提交审批"}
|
||
</Button>
|
||
) : null}
|
||
{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>
|
||
|
||
<Dialog open={isInvoiceDialogOpen} onOpenChange={closeInvoiceDialog}>
|
||
<DialogContent
|
||
className="max-h-[86dvh] overflow-y-auto p-4"
|
||
data-testid={TEST_IDS.reimbursement.invoiceDialog}
|
||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||
>
|
||
<DialogHeader className="text-left">
|
||
<DialogTitle>添加发票</DialogTitle>
|
||
<DialogDescription>可一次上传多张,识别完成后再绑定。</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="mt-4 grid grid-cols-2 gap-2 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}
|
||
{invoiceActionMessage ? (
|
||
<div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||
{invoiceActionMessage}
|
||
</div>
|
||
) : null}
|
||
|
||
{invoicePanel === "upload" ? (
|
||
<div className="mt-4 space-y-3">
|
||
<label
|
||
htmlFor="mobile-reimbursement-invoice-upload"
|
||
className="flex min-h-32 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-4 py-5 text-center"
|
||
>
|
||
<span className="material-symbols-outlined text-[30px] text-zinc-700">upload_file</span>
|
||
<span className="mt-2 text-sm font-semibold text-zinc-900">
|
||
{selectedInvoiceFiles.length ? `已选择 ${selectedInvoiceFiles.length} 个文件` : "选择或拍摄发票"}
|
||
</span>
|
||
<span className="mt-1 text-xs text-zinc-500">支持图片和 PDF,可多选</span>
|
||
</label>
|
||
<input
|
||
ref={fileInputRef}
|
||
id="mobile-reimbursement-invoice-upload"
|
||
className="sr-only"
|
||
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
|
||
type="file"
|
||
accept="image/*,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}
|
||
|
||
{pendingOcrInvoiceIds.length ? (
|
||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
|
||
{pendingOcrInvoiceIds.length} 张发票正在识别。识别完成后可在“已有发票”中绑定。
|
||
</p>
|
||
) : 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={bindableInvoicesQuery.isFetching} onClick={() => void bindableInvoicesQuery.refetch()}>
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="max-h-[44dvh] space-y-2 overflow-y-auto pr-1">
|
||
{bindableInvoicesQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
|
||
{bindableInvoicesQuery.isError ? (
|
||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||
{resolveErrorMessage(bindableInvoicesQuery.error)}
|
||
</div>
|
||
) : null}
|
||
{!bindableInvoicesQuery.isLoading && !bindableInvoicesQuery.isError && !(bindableInvoicesQuery.data?.items ?? []).length ? (
|
||
<div className="rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
|
||
暂无可绑定发票
|
||
</div>
|
||
) : null}
|
||
{(bindableInvoicesQuery.data?.items ?? []).map((invoice) => {
|
||
const checked = Object.prototype.hasOwnProperty.call(selectedInvoices, invoice.id);
|
||
return (
|
||
<label
|
||
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")}
|
||
>
|
||
<div className="flex gap-3">
|
||
<input
|
||
className="mt-1 h-4 w-4 rounded border-zinc-300"
|
||
type="checkbox"
|
||
checked={checked}
|
||
onChange={(event) => toggleInvoiceSelection(invoice, event.target.checked)}
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceTitle(invoice)}</div>
|
||
<div className="mt-1 text-xs text-zinc-500">
|
||
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)} · {formatCurrency(invoice.totalAmount)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{checked ? (
|
||
<select
|
||
className="select-std mt-3"
|
||
value={selectedInvoices[invoice.id] ?? ""}
|
||
onChange={(event) => updateSelectedInvoiceItem(invoice.id, event.target.value)}
|
||
>
|
||
<option value="">绑定整单</option>
|
||
{detail.items.map((item) => (
|
||
<option key={item.id} value={item.id}>
|
||
{itemLabel(item)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : null}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<Button
|
||
className="w-full"
|
||
type="button"
|
||
disabled={!selectedInvoiceEntries.length || 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="grid grid-cols-2 gap-2">
|
||
<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={confirmDeleteReimbursement}
|
||
onOpenChange={setConfirmDeleteReimbursement}
|
||
title="删除草稿?"
|
||
description="删除后无法恢复,已绑定发票会回到可用状态。"
|
||
variant="destructive"
|
||
confirmLabel="删除草稿"
|
||
onConfirm={() => deleteReimbursementMutation.mutate()}
|
||
/>
|
||
</PageScaffold>
|
||
);
|
||
}
|
||
|
||
export default MobileReimbursementDetailPage;
|