diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index 5ae7127b..36e31647 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -303,6 +303,12 @@ export type ReimbursementItemDeleteResponse = { totalAmount: string; }; +export type ReimbursementDeleteResponse = { + reimbursementId: string; + outcome: "deleted"; + unboundInvoiceCount: number; +}; + export type ReimbursementSubmitResponse = { reimbursementId: string; formNumber: string; @@ -415,6 +421,12 @@ export async function payReimbursement(reimbursementId: string, payload: Reimbur }); } +export async function deleteReimbursement(reimbursementId: string) { + return apiRequestData(`/api/reimbursements/${reimbursementId}`, { + method: "DELETE", + }); +} + export async function createReimbursementItem( reimbursementId: string, payload: ReimbursementItemPayload, diff --git a/client/src/lib/reimbursementDisplay.ts b/client/src/lib/reimbursementDisplay.ts new file mode 100644 index 00000000..e5c67870 --- /dev/null +++ b/client/src/lib/reimbursementDisplay.ts @@ -0,0 +1,29 @@ +import type { ReimbursementStatus } from "../api/reimbursements"; + +type ReimbursementDisplayInput = { + formNumber?: string | null; + id?: string | null; + status?: ReimbursementStatus | string | null; + reimbursementDate?: string | null; + createdAt?: string | null; +}; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function isUuidLike(value: string | null | undefined) { + return Boolean(value && UUID_PATTERN.test(value)); +} + +export function resolveReimbursementDisplayCode(item: ReimbursementDisplayInput) { + const formNumber = item.formNumber?.trim(); + if (formNumber && !isUuidLike(formNumber)) { + return formNumber; + } + + const date = item.reimbursementDate ?? item.createdAt?.slice(0, 10) ?? ""; + if (item.status === "draft" || item.status === "rejected") { + return date ? `草稿单 · ${date}` : "草稿单"; + } + + return date ? `未编号报销 · ${date}` : "未编号报销"; +} diff --git a/client/src/pages/MobileReimbursementsPage.tsx b/client/src/pages/MobileReimbursementsPage.tsx index c06a5381..aa7a8ea0 100644 --- a/client/src/pages/MobileReimbursementsPage.tsx +++ b/client/src/pages/MobileReimbursementsPage.tsx @@ -14,6 +14,7 @@ import { Button } from "../components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; import { PageHeader } from "../components/common/PageHeader"; import { PageScaffold } from "../components/patterns/PageScaffold"; +import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { resolveErrorMessage } from "../lib/resolveErrorMessage"; const STATUS_TABS: Array<{ value: ReimbursementStatus; label: string }> = [ @@ -49,12 +50,13 @@ function formatDate(value: string | null | undefined) { function ReimbursementCard({ item }: { item: ReimbursementListItem }) { const actionLabel = item.status === "draft" || item.status === "rejected" ? "继续编辑" : "查看详情"; + const displayCode = resolveReimbursementDisplayCode(item); return (
-
{item.formNumber || item.id}
+
{displayCode}
{formatDate(item.reimbursementDate)}
{STATUS_LABEL[item.status]} diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index 3fd80abe..cfa671e1 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Link, useParams } from "react-router-dom"; +import { Link, useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; @@ -9,6 +9,7 @@ import { exportReimbursementJournal, previewReimbursementJournal } from "../api/ import { fetchProjects } from "../api/projects"; import { createReimbursementItem, + deleteReimbursement, deleteReimbursementItem, fetchReimbursementDetail, payReimbursement, @@ -21,6 +22,7 @@ import { } from "../api/reimbursements"; import { bindInvoice, + type BindableInvoiceSummary, fetchBindableInvoices, fetchInvoicePoolDetail, unbindInvoice, @@ -49,6 +51,7 @@ import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr"; +import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { TEST_IDS } from "../lib/testIds"; import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions"; import { @@ -110,6 +113,7 @@ function resolveErpSyncStatus( export function ReimbursementDetailPage() { const { reimbursementId = "" } = useParams(); + const navigate = useNavigate(); const { permissions, me } = useAuth(); const queryClient = useQueryClient(); @@ -118,6 +122,7 @@ export function ReimbursementDetailPage() { const [itemEditor, setItemEditor] = useState(null); const [itemError, setItemError] = useState(""); const [confirmDeleteItemId, setConfirmDeleteItemId] = useState(""); + const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false); const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false); const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState(null); const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false); @@ -156,6 +161,7 @@ export function ReimbursementDetailPage() { 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 reimbursementPrecheckQuery = useQuery({ enabled: Boolean(reimbursementId) && canSubmitExpense && ["draft", "rejected"].includes(detailQuery.data?.status ?? ""), @@ -343,6 +349,26 @@ export function ReimbursementDetailPage() { }, }); + const deleteReimbursementMutation = useMutation({ + mutationFn: async () => { + if (!reimbursementId) { + throw new Error("missing reimbursementId"); + } + + return deleteReimbursement(reimbursementId); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["reimbursements"] }); + toast.success("草稿已删除"); + navigate("/reimbursements?status=draft"); + }, + onError: (error) => { + const message = buildActionErrorMessage(error, "删除草稿失败"); + setActionError(message); + toast.error(message); + }, + }); + const projectLinkMutation = useMutation({ mutationFn: (projectId: string | null) => linkReimbursementProject(reimbursementId, projectId), onSuccess: async () => { @@ -568,9 +594,15 @@ export function ReimbursementDetailPage() { const bindableIds = new Set((refreshed.data?.items ?? []).map((item) => item.id)); const selectableIds = result.availableIds.filter((invoiceId) => bindableIds.has(invoiceId)); if (selectableIds.length) { + const invoiceById = new Map((refreshed.data?.items ?? []).map((item) => [item.id, item])); setSelectedInvoices((previous) => ({ ...previous, - ...Object.fromEntries(selectableIds.map((invoiceId) => [invoiceId, previous[invoiceId] ?? ""])), + ...Object.fromEntries( + selectableIds.map((invoiceId) => [ + invoiceId, + previous[invoiceId] ?? resolveDefaultLinkedItemId(invoiceById.get(invoiceId)), + ]), + ), })); } @@ -579,7 +611,7 @@ export function ReimbursementDetailPage() { ...result.ocrFailures, ]; if (result.pendingIds.length) { - messages.push(`${result.pendingIds.length} 张发票仍在 OCR 处理中,请稍后刷新列表或到票据中心查看`); + messages.push(`${result.pendingIds.length} 张发票仍在 OCR 识别中,可稍后刷新`); } if (messages.length) { @@ -632,6 +664,7 @@ export function ReimbursementDetailPage() { } const detail = detailQuery.data; + const displayCode = resolveReimbursementDisplayCode(detail); const isPaidReimbursement = detail.status === "paid"; const erpSyncStatus = resolveErpSyncStatus(erpPreviewQuery.data?.exportState ?? null); const budgetHint = detail.budgetHint; @@ -644,6 +677,10 @@ export function ReimbursementDetailPage() { canUploadInvoice && canViewOwnInvoice; const canUnbindInvoices = canEditItems && (isReimbursementParticipant || canViewAllExpense); + const canDeleteReimbursement = + detail.status === "draft" && + canDeleteExpense && + (isReimbursementParticipant || canViewAllExpense); const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? []; const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block"); const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn"); @@ -749,6 +786,24 @@ export function ReimbursementDetailPage() { } }; + const resolveDefaultLinkedItemId = (invoice: BindableInvoiceSummary | null | undefined) => { + 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 toggleInvoiceSelection = (invoiceId: string, checked: boolean) => { setSelectedInvoices((previous) => { if (!checked) { @@ -757,9 +812,10 @@ export function ReimbursementDetailPage() { return next; } + const invoice = (bindableInvoicesQuery.data?.items ?? []).find((item) => item.id === invoiceId); return { ...previous, - [invoiceId]: previous[invoiceId] ?? "", + [invoiceId]: previous[invoiceId] ?? resolveDefaultLinkedItemId(invoice), }; }); }; @@ -800,8 +856,8 @@ export function ReimbursementDetailPage() { return ( @@ -819,6 +875,17 @@ export function ReimbursementDetailPage() { + {canDeleteReimbursement ? ( + + ) : null}
)} /> @@ -848,7 +915,7 @@ export function ReimbursementDetailPage() {
状态:{detail.status}
-
报销ID:{detail.id}
+
报销编号:{displayCode}
报销日期:{detail.reimbursementDate ?? "-"}
金额:{detail.totalAmount ?? "-"}
提交人:{detail.submitter?.name ?? "-"}
@@ -1248,7 +1315,7 @@ export function ReimbursementDetailPage() {
-
报销编号:{detail.formNumber ?? detail.id}
+
报销编号:{displayCode}
报销人:{detail.owner?.name ?? "-"}
提交人:{detail.submitter?.name ?? "-"}
当前审批:{detail.pendingWorkflow?.task?.step_name ?? "-"}
@@ -1310,7 +1377,7 @@ export function ReimbursementDetailPage() { > 添加发票 - 选择当前报销人可绑定的可用发票,或上传新发票等待 OCR 完成后直接绑定。 + 选择可用发票,或上传新发票。
@@ -1376,6 +1443,7 @@ export function ReimbursementDetailPage() {
绑定到明细(可选)