R7E-16E/R7E-16F 高风险操作确认统一

这个提交包含在:
2026-05-15 22:55:00 +08:00
父节点 5c38d810c1
当前提交 bf791d7a67
共修改 18 个文件,包含 979 行新增和 68 行删除
+31 -8
查看文件
@@ -34,6 +34,7 @@ import { fetchEmployeePerformanceTimeline } from "../api/performance";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { PageHeader } from "../components/common/PageHeader";
import { EmployeePerformancePanel } from "../components/people/EmployeePerformancePanel";
import { EmployeeSalaryPanel } from "../components/people/EmployeeSalaryPanel";
@@ -178,6 +179,8 @@ export function EmployeeProfilePage() {
const [trainingForm, setTrainingForm] = useState<TrainingFormState>(buildEmptyTrainingForm());
const [editingCertificateId, setEditingCertificateId] = useState<string | null>(null);
const [certificateForm, setCertificateForm] = useState<CertificateFormState>(buildEmptyCertificateForm());
const [deleteAvatarConfirmOpen, setDeleteAvatarConfirmOpen] = useState(false);
const [deleteContractTarget, setDeleteContractTarget] = useState<EmployeeContract | null>(null);
const [form, setForm] = useState({
title: "",
hireDate: "",
@@ -644,10 +647,7 @@ export function EmployeeProfilePage() {
}
function handleDeleteAvatar() {
if (!window.confirm("确认删除当前头像?")) {
return;
}
deleteAvatarMutation.mutate();
setDeleteAvatarConfirmOpen(true);
}
function handleDeleteContract(contract: EmployeeContract) {
@@ -655,13 +655,11 @@ export function EmployeeProfilePage() {
toast.error("生效中的合同需先终止后再删除");
return;
}
if (!window.confirm(`确认删除合同 ${contract.contractNo}?`)) {
return;
}
deleteContractMutation.mutate(contract.id);
setDeleteContractTarget(contract);
}
return (
<>
<div className="space-y-6">
<PageHeader
title={`${profile.employee.name} 档案`}
@@ -1437,6 +1435,31 @@ export function EmployeeProfilePage() {
) : null}
</Tabs>
</div>
<ActionConfirmDialog
open={deleteAvatarConfirmOpen}
onOpenChange={setDeleteAvatarConfirmOpen}
kind="delete"
targetName="当前头像"
description="删除后个人档案将不再显示当前头像,需要重新上传才能恢复。"
onConfirm={async () => {
deleteAvatarMutation.mutate();
}}
/>
<ActionConfirmDialog
open={Boolean(deleteContractTarget)}
onOpenChange={(open) => {
if (!open) setDeleteContractTarget(null);
}}
kind="delete"
targetName={deleteContractTarget ? `合同 ${deleteContractTarget.contractNo}` : undefined}
description="删除后该合同记录和附件引用将从员工档案中移除,生效中的合同不能删除。"
onConfirm={async () => {
if (deleteContractTarget) {
deleteContractMutation.mutate(deleteContractTarget.id);
}
}}
/>
</>
);
}
+42 -3
查看文件
@@ -17,6 +17,7 @@ import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
@@ -61,6 +62,11 @@ export function PaymentBatchesPage() {
const [defaultPaymentChannel, setDefaultPaymentChannel] = useState<PaymentChannel | "">("");
const [voucherPrefix, setVoucherPrefix] = useState("");
const [error, setError] = useState("");
const [confirmPaymentAction, setConfirmPaymentAction] = useState<{
kind: "financeReview" | "financePayment" | "cancel";
batchId: string;
batchNo: string;
} | null>(null);
const listQuery = useQuery({
queryKey: ["payment-batches"],
@@ -330,7 +336,11 @@ export function PaymentBatchesPage() {
type="button"
variant="secondary"
disabled={reviewMutation.isPending || !canReviewSelectedBatch}
onClick={() => reviewMutation.mutate(selectedBatch.id)}
onClick={() => setConfirmPaymentAction({
kind: "financeReview",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
})}
>
{selectedBatch.reviewRequirement.nextStep === "second_review" ? "二次复核" : "复核"}
</Button>
@@ -339,7 +349,11 @@ export function PaymentBatchesPage() {
<Button
type="button"
disabled={executeMutation.isPending || !canExecuteSelectedBatch}
onClick={() => executeMutation.mutate(selectedBatch.id)}
onClick={() => setConfirmPaymentAction({
kind: "financePayment",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
})}
>
执行
</Button>
@@ -349,7 +363,11 @@ export function PaymentBatchesPage() {
type="button"
variant="secondary"
disabled={cancelMutation.isPending || !canCancelSelectedBatch}
onClick={() => cancelMutation.mutate(selectedBatch.id)}
onClick={() => setConfirmPaymentAction({
kind: "cancel",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
})}
>
取消
</Button>
@@ -417,6 +435,27 @@ export function PaymentBatchesPage() {
) : null}
</div>
</div>
<ActionConfirmDialog
open={Boolean(confirmPaymentAction)}
onOpenChange={(open) => {
if (!open) setConfirmPaymentAction(null);
}}
kind={confirmPaymentAction?.kind ?? "financePayment"}
targetName={confirmPaymentAction?.batchNo}
description={
confirmPaymentAction?.kind === "financeReview"
? "确认后将写入付款批次复核记录;高风险批次仍需满足双人复核要求。"
: confirmPaymentAction?.kind === "cancel"
? "取消后该付款批次不能继续执行,历史记录会保留。"
: "确认后将执行付款批次并影响付款状态,请确认批次、金额和复核要求均已核对。"
}
onConfirm={async () => {
if (!confirmPaymentAction) return;
if (confirmPaymentAction.kind === "financeReview") reviewMutation.mutate(confirmPaymentAction.batchId);
if (confirmPaymentAction.kind === "financePayment") executeMutation.mutate(confirmPaymentAction.batchId);
if (confirmPaymentAction.kind === "cancel") cancelMutation.mutate(confirmPaymentAction.batchId);
}}
/>
</ListWorkspace>
);
}
+70 -9
查看文件
@@ -13,6 +13,7 @@ import { SectionPanel } from "../components/patterns/SectionPanel";
import { isApiError } from "../api/types";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
@@ -173,6 +174,8 @@ export function ProjectDetailPage() {
nextFollowUpDate: "",
});
const [editingCommunicationLogId, setEditingCommunicationLogId] = useState<string | null>(null);
const [waiveChecklistTarget, setWaiveChecklistTarget] = useState<{ itemId: string; currentNote: string | null } | null>(null);
const [waiveChecklistNote, setWaiveChecklistNote] = useState("");
useEffect(() => {
if (!detail) return;
@@ -452,15 +455,8 @@ export function ProjectDetailPage() {
const handleChecklistStatusChange = (itemId: string, status: ProjectClosureChecklistStatus, currentNote: string | null) => {
if (status === "waived") {
const note = window.prompt("请填写豁免理由", currentNote ?? "");
if (note == null) {
return;
}
if (!note.trim()) {
toast.error("豁免必须填写说明");
return;
}
patchClosureChecklistMutation.mutate({ itemId, status, note: note.trim() });
setWaiveChecklistTarget({ itemId, currentNote });
setWaiveChecklistNote(currentNote ?? "");
return;
}
patchClosureChecklistMutation.mutate({ itemId, status });
@@ -491,6 +487,7 @@ export function ProjectDetailPage() {
const riskItems = risksQuery.data?.items ?? detail.risks;
return (
<>
<PageScaffold>
<EntityOverviewHero
title={`${detail.projectNo} · ${detail.name}`}
@@ -1061,6 +1058,70 @@ export function ProjectDetailPage() {
</div>
</div>
</PageScaffold>
<Dialog
open={Boolean(waiveChecklistTarget)}
onOpenChange={(open) => {
if (!open) {
setWaiveChecklistTarget(null);
setWaiveChecklistNote("");
}
}}
>
<DialogContent className="max-w-md" data-testid="action-confirm-dialog">
<DialogHeader>
<DialogTitle>确认豁免收口项</DialogTitle>
<DialogDescription>
豁免后该收口项会标记为已豁免,并写入项目收口记录。必须填写可理解说明。
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-900" htmlFor="project-closure-waive-note">
豁免理由
</label>
<Textarea
id="project-closure-waive-note"
data-testid="project-closure-waive-note"
value={waiveChecklistNote}
onChange={(event) => setWaiveChecklistNote(event.target.value)}
placeholder="说明为什么该收口项可以豁免"
rows={3}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
data-testid="action-confirm-cancel"
disabled={patchClosureChecklistMutation.isPending}
onClick={() => {
setWaiveChecklistTarget(null);
setWaiveChecklistNote("");
}}
>
取消
</Button>
<Button
type="button"
variant="destructive"
data-testid="action-confirm-submit"
disabled={!waiveChecklistTarget || !waiveChecklistNote.trim() || patchClosureChecklistMutation.isPending}
onClick={() => {
if (!waiveChecklistTarget) return;
patchClosureChecklistMutation.mutate({
itemId: waiveChecklistTarget.itemId,
status: "waived",
note: waiveChecklistNote.trim(),
});
setWaiveChecklistTarget(null);
setWaiveChecklistNote("");
}}
>
{patchClosureChecklistMutation.isPending ? "处理中..." : "确认豁免"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+32 -2
查看文件
@@ -38,6 +38,7 @@ import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
@@ -244,6 +245,8 @@ export function PurchaseRequestsPage() {
const [archivePoNo, setArchivePoNo] = useState("");
const [archiveContractNo, setArchiveContractNo] = useState("");
const [selectedAttachmentName, setSelectedAttachmentName] = useState("");
const [submitPrecheckConfirm, setSubmitPrecheckConfirm] = useState<string | null>(null);
const [erpRequeueConfirm, setErpRequeueConfirm] = useState<{ target: PurchaseRequestErpRequeueTarget; label: string } | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const suppliersQuery = useQuery({
@@ -742,7 +745,8 @@ export function PurchaseRequestsPage() {
return;
}
if ((precheck.decision === "warn" || precheck.decision === "manual_review") && !window.confirm(message)) {
if (precheck.decision === "warn" || precheck.decision === "manual_review") {
setSubmitPrecheckConfirm(message);
return;
}
@@ -1736,7 +1740,7 @@ export function PurchaseRequestsPage() {
size="sm"
variant="secondary"
disabled={requeueErpMutation.isPending}
onClick={() => requeueErpMutation.mutate({ target: action.target, label: meta.title })}
onClick={() => setErpRequeueConfirm({ target: action.target, label: meta.title })}
>
{isSubmittingAction ? "处理中..." : action.label}
</Button>
@@ -1757,6 +1761,32 @@ export function PurchaseRequestsPage() {
) : null}
</div>
</div>
<ActionConfirmDialog
open={Boolean(submitPrecheckConfirm)}
onOpenChange={(open) => {
if (!open) setSubmitPrecheckConfirm(null);
}}
kind="precheck"
targetName={detail ? resolvePurchaseDisplayCode(detail) : "采购申请"}
description={submitPrecheckConfirm}
onConfirm={async () => {
submitMutation.mutate();
}}
/>
<ActionConfirmDialog
open={Boolean(erpRequeueConfirm)}
onOpenChange={(open) => {
if (!open) setErpRequeueConfirm(null);
}}
kind="retry"
targetName={erpRequeueConfirm?.label}
description="确认后会重新补发或重试 ERP 同步消息,并追加新的同步处理记录。"
onConfirm={async () => {
if (erpRequeueConfirm) {
requeueErpMutation.mutate(erpRequeueConfirm);
}
}}
/>
</ListWorkspace>
);
}
+35 -8
查看文件
@@ -26,6 +26,7 @@ import { toast } from "sonner";
import { downloadBlob } from "../lib/download";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { Pagination } from "../components/common/Pagination";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { FilterBar, ListWorkspace, PrimaryTable, StatStrip, WorkspaceHeader } from "../components/patterns/ListWorkspace";
@@ -52,6 +53,7 @@ import { Textarea } from "../components/ui/textarea";
const PAGE_SIZE = 20;
type BatchAction = "approve" | "reject";
type ExportFormat = "csv" | "xlsx";
type BatchResultState = {
action: BatchAction;
@@ -104,6 +106,8 @@ export function ReimbursementsPage() {
const [batchDialogAction, setBatchDialogAction] = useState<BatchAction | null>(null);
const [batchComment, setBatchComment] = useState("");
const [batchResult, setBatchResult] = useState<BatchResultState | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
const [exportConfirmFormat, setExportConfirmFormat] = useState<ExportFormat | null>(null);
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
const page = Number(searchParams.get("page") ?? "1") || 1;
@@ -216,15 +220,11 @@ export function ReimbursementsPage() {
});
const handleDeleteDraft = (item: ReimbursementListItem) => {
const displayCode = resolveReimbursementDisplayCode(item);
if (!window.confirm(`确认删除 ${displayCode}?删除后无法恢复。`)) {
return;
}
deleteMutation.mutate(item.id);
setDeleteTarget(item);
};
const exportMutation = useMutation({
mutationFn: (format: "csv" | "xlsx") => exportReimbursements({ format, status, scope }),
mutationFn: (format: ExportFormat) => exportReimbursements({ format, status, scope }),
onSuccess: (response, format) => {
downloadBlob(response.blob, response.filename ?? `reimbursements.${format}`);
setExportError("");
@@ -461,13 +461,13 @@ export function ReimbursementsPage() {
<DropdownMenuContent align="end">
<DropdownMenuItem
data-testid="reimbursements-export-csv"
onSelect={() => exportMutation.mutate("csv")}
onSelect={() => setExportConfirmFormat("csv")}
>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem
data-testid="reimbursements-export-xlsx"
onSelect={() => exportMutation.mutate("xlsx")}
onSelect={() => setExportConfirmFormat("xlsx")}
>
导出 Excel
</DropdownMenuItem>
@@ -943,6 +943,33 @@ export function ReimbursementsPage() {
</DialogFooter>
</DialogContent>
</Dialog>
<ActionConfirmDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
kind="delete"
targetName={deleteTarget ? resolveReimbursementDisplayCode(deleteTarget) : undefined}
description="删除后无法恢复,已提交或审批中的报销单不会受到影响。"
onConfirm={async () => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget.id);
}
}}
/>
<ActionConfirmDialog
open={Boolean(exportConfirmFormat)}
onOpenChange={(open) => {
if (!open) setExportConfirmFormat(null);
}}
kind="sensitiveExport"
targetName={exportConfirmFormat === "xlsx" ? "报销 Excel" : "报销 CSV"}
onConfirm={async () => {
if (exportConfirmFormat) {
exportMutation.mutate(exportConfirmFormat);
}
}}
/>
</ListWorkspace>
);
}
+21 -4
查看文件
@@ -25,6 +25,7 @@ import { fetchCostCenters, fetchLegalEntities } from "../api/masterData";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { EmptyStateCard } from "../components/common/EmptyStateCard";
import { PageHeader } from "../components/common/PageHeader";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
@@ -201,6 +202,7 @@ export function TravelRequestsPage() {
const [costCenterId, setCostCenterId] = useState("");
const [estimatedAmount, setEstimatedAmount] = useState("0.00");
const [segments, setSegments] = useState<DraftSegment[]>([emptySegment()]);
const [submitPrecheckConfirm, setSubmitPrecheckConfirm] = useState<string | null>(null);
const selectedQueryId = searchParams.get("id") ?? "";
const selectedBatchId = searchParams.get("batchId") ?? "";
@@ -351,7 +353,8 @@ export function TravelRequestsPage() {
return;
}
if ((precheck.decision === "warn" || precheck.decision === "manual_review") && !window.confirm(message)) {
if (precheck.decision === "warn" || precheck.decision === "manual_review") {
setSubmitPrecheckConfirm(message);
return;
}
@@ -510,8 +513,9 @@ export function TravelRequestsPage() {
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
return (
<div className="space-y-6">
<PageHeader
<>
<div className="space-y-6">
<PageHeader
title="差旅管理"
subtitle="差旅申请、对公支付轨道与批次对账在同一路径统一收口。"
actions={
@@ -918,7 +922,20 @@ export function TravelRequestsPage() {
) : (
<EmptyStateCard title="暂无可访问的差旅页签" description="当前账号缺少差旅申请与对账访问权限。" />
)}
</div>
</div>
<ActionConfirmDialog
open={Boolean(submitPrecheckConfirm)}
onOpenChange={(open) => {
if (!open) setSubmitPrecheckConfirm(null);
}}
kind="precheck"
targetName={detail?.requestNo ?? "差旅申请"}
description={submitPrecheckConfirm}
onConfirm={async () => {
submitMutation.mutate();
}}
/>
</>
);
}
+40 -2
查看文件
@@ -9,6 +9,7 @@ import {
type WorkflowDefinitionEditorModel,
} from "../api/workflowDefinitions";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
@@ -29,6 +30,11 @@ export function WorkflowDefinitionsPage() {
const [name, setName] = useState("");
const [notes, setNotes] = useState("");
const [steps, setSteps] = useState<DraftStep[]>([emptyStep()]);
const [confirmDefinitionAction, setConfirmDefinitionAction] = useState<{
action: "publish" | "archive";
id: string;
name: string;
} | null>(null);
const listQuery = useQuery({ queryKey: ["workflow-definitions"], queryFn: () => fetchWorkflowDefinitions() });
const items = listQuery.data?.items ?? [];
@@ -114,8 +120,21 @@ export function WorkflowDefinitionsPage() {
{selected.status !== "derived_readonly" ? (
<div className="flex gap-2">
<Button type="button" variant="secondary" onClick={() => saveMutation.mutate(selected.id)} disabled={selected.status !== "draft"}>保存</Button>
<Button type="button" onClick={() => publishMutation.mutate(selected.id)} disabled={selected.status === "archived"}>发布</Button>
<Button type="button" variant="secondary" onClick={() => archiveMutation.mutate(selected.id)} disabled={selected.status === "archived"}>归档</Button>
<Button
type="button"
onClick={() => setConfirmDefinitionAction({ action: "publish", id: selected.id, name: selected.name })}
disabled={selected.status === "archived"}
>
发布
</Button>
<Button
type="button"
variant="secondary"
onClick={() => setConfirmDefinitionAction({ action: "archive", id: selected.id, name: selected.name })}
disabled={selected.status === "archived"}
>
归档
</Button>
</div>
) : null}
</div>
@@ -163,6 +182,25 @@ export function WorkflowDefinitionsPage() {
)}
</div>
</div>
<ActionConfirmDialog
open={Boolean(confirmDefinitionAction)}
onOpenChange={(open) => {
if (!open) setConfirmDefinitionAction(null);
}}
kind={confirmDefinitionAction?.action === "archive" ? "archive" : "submit"}
targetName={confirmDefinitionAction?.name}
title={confirmDefinitionAction?.action === "archive" ? "确认归档流程定义" : "确认发布流程定义"}
description={
confirmDefinitionAction?.action === "archive"
? "归档后该流程定义不再用于新流程,历史流程不回写。"
: "发布后该流程定义会参与后续新提交业务的审批解析,历史流程不回写。"
}
onConfirm={async () => {
if (!confirmDefinitionAction) return;
if (confirmDefinitionAction.action === "publish") publishMutation.mutate(confirmDefinitionAction.id);
if (confirmDefinitionAction.action === "archive") archiveMutation.mutate(confirmDefinitionAction.id);
}}
/>
</ListWorkspace>
);
}
@@ -1,7 +1,11 @@
import { useState } from "react";
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
import type { InvoicePoolDetail, InvoiceVerificationRecord } from "../../api/invoicePool";
import { ConfirmDialog } from "../../components/common/ConfirmDialog";
import { Button } from "../../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import { formatInvoiceOcrErrorMessage } from "../../lib/invoiceOcr";
export type InvoiceDetailPanelProps = {
@@ -138,6 +142,9 @@ export function InvoiceDetailPanel({
confirmAction,
setConfirmAction,
}: InvoiceDetailPanelProps) {
const [duplicateOverrideTarget, setDuplicateOverrideTarget] = useState<string | null>(null);
const [duplicateOverrideRefFormNumber, setDuplicateOverrideRefFormNumber] = useState("");
const [duplicateOverrideReason, setDuplicateOverrideReason] = useState("");
const invoiceNo = detail
? [detail.invoiceCode, detail.invoiceNumber].filter(Boolean).join("-") || detail.originalName || "待识别"
: "";
@@ -148,6 +155,17 @@ export function InvoiceDetailPanel({
: detail?.linkedItemId
? "已绑定明细"
: "未绑定";
const canSubmitDuplicateOverride = Boolean(
duplicateOverrideTarget &&
duplicateOverrideRefFormNumber.trim() &&
duplicateOverrideReason.trim() &&
!overrideDuplicateMutation.isPending,
);
const resetDuplicateOverride = () => {
setDuplicateOverrideTarget(null);
setDuplicateOverrideRefFormNumber("");
setDuplicateOverrideReason("");
};
return (
<>
@@ -291,16 +309,9 @@ export function InvoiceDetailPanel({
type="button"
disabled={overrideDuplicateMutation.isPending}
onClick={() => {
if (!window.confirm("确认执行判重例外?该操作将写入审计并影响判重口径。")) return;
const refFormNumber = window.prompt("请输入参考单号(必填)");
if (!refFormNumber?.trim()) return;
const reason = window.prompt("请输入例外原因(必填)");
if (!reason?.trim()) return;
overrideDuplicateMutation.mutate({
invoiceId: detail.id,
refFormNumber: refFormNumber.trim(),
reason: reason.trim(),
});
setDuplicateOverrideTarget(detail.id);
setDuplicateOverrideRefFormNumber("");
setDuplicateOverrideReason("");
}}
>
判重例外
@@ -515,6 +526,76 @@ export function InvoiceDetailPanel({
}}
onOpenChange={(open) => { if (!open) setConfirmAction(null); }}
/>
<Dialog
open={Boolean(duplicateOverrideTarget)}
onOpenChange={(open) => {
if (!open) resetDuplicateOverride();
}}
>
<DialogContent className="max-w-md" data-testid="action-confirm-dialog">
<DialogHeader>
<DialogTitle>确认判重例外</DialogTitle>
<DialogDescription>
确认后将写入审计记录,并影响该票据后续判重口径。请填写参考单号和例外原因。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-900" htmlFor="invoice-duplicate-ref-form-number">
参考单号
</label>
<Input
id="invoice-duplicate-ref-form-number"
data-testid="invoice-duplicate-ref-form-number"
value={duplicateOverrideRefFormNumber}
onChange={(event) => setDuplicateOverrideRefFormNumber(event.target.value)}
placeholder="例如:BX-20260514-0016"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-900" htmlFor="invoice-duplicate-override-reason">
例外原因
</label>
<Textarea
id="invoice-duplicate-override-reason"
data-testid="invoice-duplicate-override-reason"
value={duplicateOverrideReason}
onChange={(event) => setDuplicateOverrideReason(event.target.value)}
placeholder="说明为什么该票据可以作为例外处理"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
data-testid="action-confirm-cancel"
disabled={overrideDuplicateMutation.isPending}
onClick={resetDuplicateOverride}
>
取消
</Button>
<Button
type="button"
variant="destructive"
data-testid="action-confirm-submit"
disabled={!canSubmitDuplicateOverride}
onClick={() => {
if (!duplicateOverrideTarget) return;
overrideDuplicateMutation.mutate({
invoiceId: duplicateOverrideTarget,
refFormNumber: duplicateOverrideRefFormNumber.trim(),
reason: duplicateOverrideReason.trim(),
});
resetDuplicateOverride();
}}
>
{overrideDuplicateMutation.isPending ? "处理中..." : "确认例外"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}