R7E-16E/R7E-16F 高风险操作确认统一
这个提交包含在:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "./helpers/fixtures";
|
||||
import { login } from "./helpers/auth";
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
function envelope<T>(data: T) {
|
||||
return { requestId: "mock-system-action-confirm", data };
|
||||
}
|
||||
|
||||
async function fulfillJson<T>(route: Route, data: T) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope(data)),
|
||||
});
|
||||
}
|
||||
|
||||
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
|
||||
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
|
||||
}
|
||||
|
||||
const user = {
|
||||
identifier: "[email protected]",
|
||||
password: "ActionConfirm1234",
|
||||
name: "Action Confirm",
|
||||
};
|
||||
|
||||
const me = {
|
||||
id: "employee-action-confirm",
|
||||
employeeNo: "E-action-confirm",
|
||||
name: user.name,
|
||||
email: user.identifier,
|
||||
phone: null,
|
||||
roleCodes: ["manager", "finance"],
|
||||
permissions: [
|
||||
"expense.create",
|
||||
"expense.view_own",
|
||||
"expense.delete_own",
|
||||
"expense.approve",
|
||||
"payment.batch.manage",
|
||||
"risk.review.manage",
|
||||
],
|
||||
profileCompleteness: {
|
||||
hasEmergencyContact: true,
|
||||
hasBankAccount: true,
|
||||
hasAvatar: false,
|
||||
hasIdNumber: false,
|
||||
score: 80,
|
||||
},
|
||||
};
|
||||
|
||||
const reimbursementId = "reimbursement-action-confirm";
|
||||
const paymentBatchId = "payment-batch-action-confirm";
|
||||
|
||||
function reimbursementListItem() {
|
||||
return {
|
||||
id: reimbursementId,
|
||||
formNumber: "BX-ACTION-001",
|
||||
status: "draft",
|
||||
reimbursementDate: "2026-05-15",
|
||||
totalAmount: "88.00",
|
||||
description: "R7E 高风险确认测试",
|
||||
payment: {
|
||||
paymentVoucherNo: null,
|
||||
paymentDate: null,
|
||||
paymentChannel: null,
|
||||
paidAt: null,
|
||||
paidBy: null,
|
||||
},
|
||||
owner: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||
submitter: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||
project: null,
|
||||
pendingWorkflow: null,
|
||||
createdAt: "2026-05-15T08:00:00.000Z",
|
||||
updatedAt: "2026-05-15T08:10:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function paymentBatch(status: "draft" | "reviewed" = "reviewed") {
|
||||
return {
|
||||
id: paymentBatchId,
|
||||
batchNo: "PAY-ACTION-001",
|
||||
entityType: "reimbursement",
|
||||
name: "R7E 高风险付款确认",
|
||||
status,
|
||||
defaultPaymentDate: "2026-05-15",
|
||||
defaultPaymentChannel: "bank",
|
||||
voucherPrefix: "R7E",
|
||||
totalAmount: "88.00",
|
||||
highRisk: false,
|
||||
reviewRequirement: {
|
||||
thresholdAmount: "1000.00",
|
||||
requiredReviewCount: 1,
|
||||
completedReviewCount: status === "draft" ? 0 : 1,
|
||||
nextStep: status === "draft" ? "first_review" : "ready_for_execute",
|
||||
makerCheckerRequired: false,
|
||||
reviewerPermission: "risk.review.manage",
|
||||
},
|
||||
reviewComment: null,
|
||||
secondaryReviewComment: null,
|
||||
executionSummaryJson: null,
|
||||
createdAt: "2026-05-15T08:00:00.000Z",
|
||||
createdBy: me.id,
|
||||
reviewedAt: status === "draft" ? null : "2026-05-15T08:10:00.000Z",
|
||||
reviewedBy: status === "draft" ? null : me.id,
|
||||
secondaryReviewedAt: null,
|
||||
secondaryReviewedBy: null,
|
||||
executedAt: null,
|
||||
executedBy: null,
|
||||
cancelledAt: null,
|
||||
cancelledBy: null,
|
||||
items: [
|
||||
{
|
||||
id: "payment-batch-item-action-confirm",
|
||||
entityId: reimbursementId,
|
||||
entityCode: "BX-ACTION-001",
|
||||
entityTitle: "R7E 高风险确认测试",
|
||||
amount: "88.00",
|
||||
currencyCode: "CNY",
|
||||
paymentVoucherNoOverride: null,
|
||||
paymentDateOverride: null,
|
||||
paymentChannelOverride: null,
|
||||
status: "pending",
|
||||
failureCode: null,
|
||||
failureMessage: null,
|
||||
snapshotJson: {},
|
||||
createdAt: "2026-05-15T08:00:00.000Z",
|
||||
openRiskFlags: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function mockSystemActionApis(page: Page) {
|
||||
const calls = {
|
||||
deleteReimbursement: 0,
|
||||
exportReimbursements: 0,
|
||||
executePaymentBatch: 0,
|
||||
};
|
||||
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === "/api/auth/sso/providers") {
|
||||
await fulfillJson(route, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/wecom/config") {
|
||||
await fulfillJson(route, {
|
||||
enabled: true,
|
||||
entryEnabled: true,
|
||||
oauthEnabled: true,
|
||||
corpId: "wx-test-corp",
|
||||
agentId: "1000002",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal",
|
||||
bindEnabled: true,
|
||||
defaultReturnTo: "/portal",
|
||||
status: "ready",
|
||||
statusReasons: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/login" || path === "/api/auth/refresh") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||
body: JSON.stringify(envelope({
|
||||
accessToken: "mock-system-action-confirm-token",
|
||||
expiresIn: 900,
|
||||
mustChangePassword: false,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/logout") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=; Path=/; Max-Age=0" },
|
||||
body: JSON.stringify(envelope({})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/me") {
|
||||
await fulfillJson(route, me);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/system/navigation") {
|
||||
await fulfillJson(route, {
|
||||
workspace: {
|
||||
id: "workspace-action-confirm",
|
||||
persisted: true,
|
||||
profile: "small_team_internal",
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "password",
|
||||
enabledGroups: ["main"],
|
||||
quickActions: [],
|
||||
},
|
||||
items: [
|
||||
navigationItem("portal", "/portal", "首页", "home"),
|
||||
navigationItem("reimbursements", "/reimbursements", "报销", "receipt"),
|
||||
navigationItem("payment-batches", "/payment-batches", "付款批次", "credit_card"),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/reimbursements" && request.method() === "GET") {
|
||||
await fulfillJson(route, { page: 1, pageSize: 20, total: 1, scope: "own", items: [reimbursementListItem()] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "DELETE") {
|
||||
calls.deleteReimbursement += 1;
|
||||
await fulfillJson(route, { id: reimbursementId, deleted: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/exports/reimbursements") {
|
||||
calls.exportReimbursements += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: url.searchParams.get("format") === "xlsx"
|
||||
? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
: "text/csv",
|
||||
headers: { "content-disposition": "attachment; filename=\"reimbursements.csv\"" },
|
||||
body: "formNumber,totalAmount\nBX-ACTION-001,88.00\n",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/reimbursement-templates") {
|
||||
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/reimbursements/linkable-travel-requests") {
|
||||
await fulfillJson(route, { ownerEmployeeId: me.id, items: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/payment-batches" && request.method() === "GET") {
|
||||
await fulfillJson(route, { items: [paymentBatch("reviewed")] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === `/api/payment-batches/${paymentBatchId}/execute` && request.method() === "POST") {
|
||||
calls.executePaymentBatch += 1;
|
||||
await fulfillJson(route, { item: paymentBatch("reviewed") });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, {});
|
||||
});
|
||||
|
||||
return calls;
|
||||
}
|
||||
|
||||
test.describe("R7E 全站高风险操作二次确认", () => {
|
||||
test("报销删除和敏感导出必须确认后才调用接口", async ({ page }) => {
|
||||
const calls = await mockSystemActionApis(page);
|
||||
page.on("dialog", (dialog) => {
|
||||
throw new Error(`不应出现浏览器原生弹窗:${dialog.message()}`);
|
||||
});
|
||||
|
||||
await login(page, user);
|
||||
await page.goto("/reimbursements");
|
||||
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
|
||||
|
||||
await page.getByTestId("reimbursement-row").getByRole("button", { name: "删除" }).click();
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("删除后无法恢复");
|
||||
expect(calls.deleteReimbursement).toBe(0);
|
||||
|
||||
await page.getByTestId("action-confirm-cancel").click();
|
||||
expect(calls.deleteReimbursement).toBe(0);
|
||||
|
||||
await page.getByTestId("reimbursement-row").getByRole("button", { name: "删除" }).click();
|
||||
await page.getByTestId("action-confirm-submit").click();
|
||||
await expect.poll(() => calls.deleteReimbursement).toBe(1);
|
||||
|
||||
await page.getByTestId("reimbursements-export").click();
|
||||
await page.getByTestId("reimbursements-export-csv").click();
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("导出文件可能包含业务或个人信息");
|
||||
expect(calls.exportReimbursements).toBe(0);
|
||||
|
||||
await page.getByTestId("action-confirm-submit").click();
|
||||
await expect.poll(() => calls.exportReimbursements).toBe(1);
|
||||
});
|
||||
|
||||
test("付款批次执行必须确认后才调用接口", async ({ page }) => {
|
||||
const calls = await mockSystemActionApis(page);
|
||||
page.on("dialog", (dialog) => {
|
||||
throw new Error(`不应出现浏览器原生弹窗:${dialog.message()}`);
|
||||
});
|
||||
|
||||
await login(page, user);
|
||||
await page.goto("/payment-batches");
|
||||
await expect(page.getByText("PAY-ACTION-001").first()).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "执行" }).click();
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认后将执行付款批次");
|
||||
expect(calls.executePaymentBatch).toBe(0);
|
||||
|
||||
await page.getByTestId("action-confirm-cancel").click();
|
||||
expect(calls.executePaymentBatch).toBe(0);
|
||||
|
||||
await page.getByRole("button", { name: "执行" }).click();
|
||||
await page.getByTestId("action-confirm-submit").click();
|
||||
await expect.poll(() => calls.executePaymentBatch).toBe(1);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户