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

这个提交包含在:
2026-05-15 22:55:00 +08:00
父节点 5c38d810c1
当前提交 bf791d7a67
共修改 18 个文件,包含 979 行新增和 68 行删除
@@ -0,0 +1,52 @@
import type { ReactNode } from "react";
import { getActionConfirmCopy, type ActionConfirmKind } from "../../lib/actionConfirm";
import { ConfirmDialog } from "./ConfirmDialog";
type ActionConfirmDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
kind: ActionConfirmKind;
targetName?: string;
count?: number;
title?: string;
description?: ReactNode;
confirmLabel?: string;
variant?: "default" | "destructive";
testId?: string;
confirmTestId?: string;
cancelTestId?: string;
onConfirm: () => void | Promise<void>;
};
export function ActionConfirmDialog({
open,
onOpenChange,
kind,
targetName,
count,
title,
description,
confirmLabel,
variant,
testId,
confirmTestId,
cancelTestId,
onConfirm,
}: ActionConfirmDialogProps) {
const copy = getActionConfirmCopy(kind, { targetName, count, detail: description });
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title={title ?? copy.title}
description={description ?? copy.description}
confirmLabel={confirmLabel ?? copy.confirmLabel}
variant={variant ?? copy.variant}
testId={testId}
confirmTestId={confirmTestId}
cancelTestId={cancelTestId}
onConfirm={onConfirm}
/>
);
}
@@ -31,9 +31,9 @@ export function ConfirmDialog({
confirmLabel = "确认",
cancelLabel = "取消",
variant = "default",
testId,
confirmTestId,
cancelTestId,
testId = "action-confirm-dialog",
confirmTestId = "action-confirm-submit",
cancelTestId = "action-confirm-cancel",
onConfirm,
}: ConfirmDialogProps) {
const [pending, setPending] = useState(false);
@@ -25,8 +25,10 @@ import { EmptyStateCard } from '../common/EmptyStateCard';
import { Pagination } from '../common/Pagination';
import { StatusBadge } from '../common/StatusBadge';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
const PAGE_SIZE = 10;
@@ -68,6 +70,8 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
const [costCenterFilter, setCostCenterFilter] = useState('');
const [periodStartFilter, setPeriodStartFilter] = useState('');
const [periodEndFilter, setPeriodEndFilter] = useState('');
const [confirmItemTarget, setConfirmItemTarget] = useState<TravelReconciliationItem | null>(null);
const [confirmItemNote, setConfirmItemNote] = useState('');
const [page, setPage] = useState(1);
const [actionItemId, setActionItemId] = useState<string | null>(null);
@@ -248,11 +252,8 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
});
function handleConfirm(item: TravelReconciliationItem) {
const note = window.prompt('确认备注(可选)', item.confirmNote ?? '');
if (note === null) {
return;
}
confirmMutation.mutate({ item, note: note.trim() || null });
setConfirmItemTarget(item);
setConfirmItemNote(item.confirmNote ?? '');
}
const detail = detailQuery.data ?? null;
@@ -260,6 +261,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
return (
<>
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<section className="space-y-6">
{canCreateBatch ? (
@@ -520,5 +522,64 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
) : null}
</section>
</div>
<Dialog
open={Boolean(confirmItemTarget)}
onOpenChange={(open) => {
if (!open) {
setConfirmItemTarget(null);
setConfirmItemNote('');
}
}}
>
<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="travel-reconciliation-confirm-note">
确认备注
</label>
<Textarea
id="travel-reconciliation-confirm-note"
data-testid="travel-reconciliation-confirm-note"
value={confirmItemNote}
onChange={(event) => setConfirmItemNote(event.target.value)}
placeholder="可补充异常确认说明"
rows={3}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
data-testid="action-confirm-cancel"
disabled={confirmMutation.isPending}
onClick={() => {
setConfirmItemTarget(null);
setConfirmItemNote('');
}}
>
取消
</Button>
<Button
type="button"
data-testid="action-confirm-submit"
disabled={!confirmItemTarget || confirmMutation.isPending}
onClick={() => {
if (!confirmItemTarget) return;
confirmMutation.mutate({ item: confirmItemTarget, note: confirmItemNote.trim() || null });
setConfirmItemTarget(null);
setConfirmItemNote('');
}}
>
{confirmMutation.isPending ? '处理中...' : '确认异常'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+142
查看文件
@@ -0,0 +1,142 @@
import type { ReactNode } from "react";
export type ActionConfirmKind =
| "submit"
| "precheck"
| "delete"
| "archive"
| "withdraw"
| "enable"
| "disable"
| "batchApprove"
| "batchReject"
| "financeReview"
| "financePayment"
| "duplicateOverride"
| "sensitiveExport"
| "retry"
| "unbind"
| "cancel";
export type ActionConfirmCopy = {
title: string;
description: ReactNode;
confirmLabel: string;
variant: "default" | "destructive";
};
export type ActionConfirmContext = {
targetName?: string;
count?: number;
detail?: ReactNode;
};
function withTarget(base: string, targetName?: string) {
return targetName ? `${base}:${targetName}` : base;
}
export function getActionConfirmCopy(kind: ActionConfirmKind, context: ActionConfirmContext = {}): ActionConfirmCopy {
const { targetName, count, detail } = context;
const countText = typeof count === "number" && count > 0 ? `${count} 项` : "所选项目";
const copies: Record<ActionConfirmKind, ActionConfirmCopy> = {
submit: {
title: withTarget("确认提交审批", targetName),
description: detail ?? "提交后将进入审批流程,系统会生成待办、通知审批人并写入提交历史。",
confirmLabel: "确认提交",
variant: "default",
},
precheck: {
title: withTarget("确认继续提交", targetName),
description: detail ?? "当前提交存在提示项。确认后将继续提交审批,并按系统规则留痕。",
confirmLabel: "继续提交",
variant: "default",
},
delete: {
title: withTarget("确认删除", targetName),
description: detail ?? "删除后将无法在当前列表继续使用,相关业务记录可能无法恢复。",
confirmLabel: "确认删除",
variant: "destructive",
},
archive: {
title: withTarget("确认归档", targetName),
description: detail ?? "归档后将不再用于新的业务流转,历史记录仍保留。",
confirmLabel: "确认归档",
variant: "destructive",
},
withdraw: {
title: withTarget("确认撤回", targetName),
description: detail ?? "撤回后当前审批流程会终止或回到可编辑状态,并写入撤回历史。",
confirmLabel: "确认撤回",
variant: "destructive",
},
enable: {
title: withTarget("确认启用", targetName),
description: detail ?? "启用后该配置会重新参与后续业务判断,只影响新发生的业务。",
confirmLabel: "确认启用",
variant: "default",
},
disable: {
title: withTarget("确认停用", targetName),
description: detail ?? "停用后该配置不再参与后续业务判断,历史记录不回写。",
confirmLabel: "确认停用",
variant: "destructive",
},
batchApprove: {
title: `确认批量通过${count ? ` ${countText}` : ""}`,
description: detail ?? "系统会逐条处理所选待办;每条结果都会写入审批历史。",
confirmLabel: "确认批量通过",
variant: "default",
},
batchReject: {
title: `确认批量驳回${count ? ` ${countText}` : ""}`,
description: detail ?? "系统会逐条驳回所选待办;驳回意见会写入审批历史。",
confirmLabel: "确认批量驳回",
variant: "destructive",
},
financeReview: {
title: withTarget("确认财务复核", targetName),
description: detail ?? "确认后将更新财务复核结果,并写入财务处理记录。",
confirmLabel: "确认复核",
variant: "default",
},
financePayment: {
title: withTarget("确认财务付款动作", targetName),
description: detail ?? "确认后将影响付款批次或付款状态,并写入财务审计记录。",
confirmLabel: "确认执行",
variant: "destructive",
},
duplicateOverride: {
title: withTarget("确认判重例外", targetName),
description: detail ?? "确认后将写入审计记录,并影响该票据后续判重口径。",
confirmLabel: "确认例外",
variant: "destructive",
},
sensitiveExport: {
title: withTarget("确认导出敏感数据", targetName),
description: detail ?? "导出文件可能包含业务或个人信息,请确认当前账号和用途合规。",
confirmLabel: "确认导出",
variant: "default",
},
retry: {
title: withTarget("确认重试", targetName),
description: detail ?? "确认后系统会重新发起该动作,并追加新的处理记录。",
confirmLabel: "确认重试",
variant: "default",
},
unbind: {
title: withTarget("确认解绑", targetName),
description: detail ?? "解绑后当前对象关系会被移除,后续需重新绑定才能继续使用。",
confirmLabel: "确认解绑",
variant: "destructive",
},
cancel: {
title: withTarget("确认取消", targetName),
description: detail ?? "取消后该业务动作将停止,系统会保留操作历史。",
confirmLabel: "确认取消",
variant: "destructive",
},
};
return copies[kind];
}
+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>
</>
);
}