文件
hyb-oa/client/src/pages/ApprovalRulesPage.tsx
T

517 行
19 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import {
createApprovalRule,
fetchApprovalRules,
patchApprovalRule,
type ApprovalRoleCode,
type ApprovalRule,
} from "../api/approvalRules";
import { PageHeader } from "../components/common/PageHeader";
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";
import { toast } from "sonner";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
const ROLE_OPTIONS: Array<{ code: ApprovalRoleCode; label: string }> = [
{ code: "manager", label: "manager" },
{ code: "ceo", label: "ceo" },
{ code: "finance", label: "finance" },
{ code: "finance_mgr", label: "finance_mgr" },
{ code: "admin", label: "admin" },
];
function formatAmount(value: string | null) {
if (!value) return "-";
return value;
}
export function ApprovalRulesPage() {
const queryClient = useQueryClient();
const [error, setError] = useState("");
const [name, setName] = useState("");
const [minAmount, setMinAmount] = useState("");
const [maxAmount, setMaxAmount] = useState("");
const [priority, setPriority] = useState("");
const [level1Role, setLevel1Role] = useState<ApprovalRoleCode>("manager");
const [level2Role, setLevel2Role] = useState<ApprovalRoleCode | "">("");
const [level3Role, setLevel3Role] = useState<ApprovalRoleCode | "">("");
const [enabled, setEnabled] = useState(true);
const [editOpen, setEditOpen] = useState(false);
const [editRule, setEditRule] = useState<ApprovalRule | null>(null);
const [confirmToggleRule, setConfirmToggleRule] = useState<ApprovalRule | null>(null);
const listQuery = useQuery({
queryKey: ["approval-rules"],
queryFn: () => fetchApprovalRules(),
});
const rules = listQuery.data?.items ?? [];
const createPayload = useMemo(() => {
return {
name: name.trim(),
minAmount: minAmount.trim(),
maxAmount: maxAmount.trim() ? maxAmount.trim() : null,
level1Role,
level2Role: level2Role ? (level2Role as ApprovalRoleCode) : null,
level3Role: level3Role ? (level3Role as ApprovalRoleCode) : null,
enabled,
priority: priority.trim() ? Number(priority) : undefined,
};
}, [enabled, level1Role, level2Role, level3Role, maxAmount, minAmount, name, priority]);
const createMutation = useMutation({
mutationFn: () => createApprovalRule(createPayload),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["approval-rules"] });
setName("");
setMinAmount("");
setMaxAmount("");
setPriority("");
setLevel1Role("manager");
setLevel2Role("");
setLevel3Role("");
setEnabled(true);
setError("");
toast.success("规则已创建");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "创建审批规则失败";
setError(msg);
toast.error(msg);
},
});
const toggleEnabledMutation = useMutation({
mutationFn: (rule: ApprovalRule) => patchApprovalRule(rule.id, { enabled: !rule.enabled }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["approval-rules"] });
setError("");
toast.success("状态已更新");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新审批规则失败";
setError(msg);
toast.error(msg);
},
});
const editMutation = useMutation({
mutationFn: async () => {
if (!editRule) {
throw new Error("请选择要编辑的规则");
}
return patchApprovalRule(editRule.id, createPayload);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["approval-rules"] });
setEditOpen(false);
setEditRule(null);
setError("");
toast.success("规则已更新");
},
onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新审批规则失败";
setError(msg);
toast.error(msg);
},
});
useEffect(() => {
if (!editOpen || !editRule) {
return;
}
setName(editRule.name);
setMinAmount(editRule.minAmount);
setMaxAmount(editRule.maxAmount ?? "");
setPriority(String(editRule.priority));
setEnabled(editRule.enabled);
setLevel1Role(editRule.level1Role);
setLevel2Role(editRule.level2Role ?? "");
setLevel3Role(editRule.level3Role ?? "");
}, [editOpen, editRule]);
return (
<div className="space-y-6">
<PageHeader
title="审批规则"
subtitle="维护金额段与审批层级(仅影响新提交的报销单,历史流程不回写)。"
/>
{error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{error}
</p>
) : null}
<div className="card-std p-6 space-y-4">
<h3 className="text-base font-semibold text-zinc-900">新建审批规则</h3>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
<Label htmlFor="ruleName">规则名称</Label>
<Input
id="ruleName"
data-testid="approval-rules-create-name"
placeholder="5000 以下走经理"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="minAmount">最小金额</Label>
<Input
id="minAmount"
data-testid="approval-rules-create-min"
placeholder="0.00"
value={minAmount}
onChange={(event) => setMinAmount(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="maxAmount">最大金额(可空)</Label>
<Input
id="maxAmount"
data-testid="approval-rules-create-max"
placeholder="5000.00"
value={maxAmount}
onChange={(event) => setMaxAmount(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="priority">优先级(越小越先匹配)</Label>
<Input
id="priority"
data-testid="approval-rules-create-priority"
placeholder="1"
value={priority}
onChange={(event) => setPriority(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="level1Role">一级审批角色</Label>
<select
id="level1Role"
data-testid="approval-rules-create-level1"
className="select-std"
value={level1Role}
onChange={(event) => setLevel1Role(event.target.value as ApprovalRoleCode)}
>
{ROLE_OPTIONS.map((role) => (
<option key={role.code} value={role.code}>
{role.label}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="level2Role">二级审批角色(可空)</Label>
<select
id="level2Role"
data-testid="approval-rules-create-level2"
className="select-std"
value={level2Role}
onChange={(event) => setLevel2Role(event.target.value as ApprovalRoleCode)}
>
<option value="">无</option>
{ROLE_OPTIONS.map((role) => (
<option key={role.code} value={role.code}>
{role.label}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="level3Role">三级审批角色(可空)</Label>
<select
id="level3Role"
data-testid="approval-rules-create-level3"
className="select-std"
value={level3Role}
onChange={(event) => setLevel3Role(event.target.value as ApprovalRoleCode)}
>
<option value="">无</option>
{ROLE_OPTIONS.map((role) => (
<option key={role.code} value={role.code}>
{role.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2 pt-6">
<input
id="ruleEnabled"
data-testid="approval-rules-create-enabled"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
type="checkbox"
/>
<Label htmlFor="ruleEnabled">启用</Label>
</div>
<div className="flex items-end">
<Button
data-testid="approval-rules-create-submit"
type="button"
disabled={createMutation.isPending || !name.trim() || !minAmount.trim()}
onClick={() => createMutation.mutate()}
>
{createMutation.isPending ? "创建中..." : "创建"}
</Button>
</div>
</div>
</div>
<div className="card-std p-6 space-y-4">
<h3 className="text-base font-semibold text-zinc-900">规则列表</h3>
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载审批规则失败"}
</p>
) : null}
{!listQuery.isLoading && !listQuery.isError ? (
<div className="rounded-lg border border-zinc-200">
<Table data-testid="approval-rules-table">
<TableHeader>
<tr>
<TableHead>名称</TableHead>
<TableHead>金额段</TableHead>
<TableHead>层级</TableHead>
<TableHead>优先级</TableHead>
<TableHead>状态</TableHead>
<TableHead className="w-44">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{rules.length ? (
rules.map((rule) => (
<TableRow key={rule.id} data-testid="approval-rule-row">
<TableCell>
<div className="font-medium text-zinc-900">{rule.name}</div>
<div className="font-mono text-xs text-zinc-500">{rule.id}</div>
</TableCell>
<TableCell className="text-sm text-zinc-700">
{formatAmount(rule.minAmount)} ~ {rule.maxAmount ? formatAmount(rule.maxAmount) : "∞"}
</TableCell>
<TableCell className="text-sm text-zinc-700">
{[rule.level1Role, rule.level2Role, rule.level3Role].filter(Boolean).join(" -> ") || "-"}
</TableCell>
<TableCell>{rule.priority}</TableCell>
<TableCell className="text-sm">
{rule.enabled ? <span className="text-emerald-600">启用</span> : <span className="text-zinc-500">停用</span>}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
data-testid="approval-rule-edit"
size="sm"
variant="secondary"
type="button"
onClick={() => {
setEditRule(rule);
setEditOpen(true);
}}
>
编辑
</Button>
<Button
data-testid="approval-rule-toggle"
size="sm"
variant="secondary"
type="button"
disabled={toggleEnabledMutation.isPending}
onClick={() => setConfirmToggleRule(rule)}
>
{rule.enabled ? "停用" : "启用"}
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={6}>
暂无审批规则
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
) : null}
</div>
<ConfirmDialog
open={confirmToggleRule !== null}
onOpenChange={(open) => { if (!open) setConfirmToggleRule(null); }}
title={`确认${confirmToggleRule?.enabled ? "停用" : "启用"}该审批规则?`}
onConfirm={() => { if (confirmToggleRule) toggleEnabledMutation.mutate(confirmToggleRule); }}
/>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>编辑审批规则</DialogTitle>
<DialogDescription>修改后只影响新提交的报销单。</DialogDescription>
</DialogHeader>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<div className="space-y-1 md:col-span-2">
<Label htmlFor="editRuleName">规则名称</Label>
<Input
id="editRuleName"
data-testid="approval-rule-edit-name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="editRuleMin">最小金额</Label>
<Input
id="editRuleMin"
data-testid="approval-rule-edit-min"
value={minAmount}
onChange={(event) => setMinAmount(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="editRuleMax">最大金额(可空)</Label>
<Input
id="editRuleMax"
data-testid="approval-rule-edit-max"
value={maxAmount}
onChange={(event) => setMaxAmount(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="editRulePriority">优先级</Label>
<Input
id="editRulePriority"
data-testid="approval-rule-edit-priority"
value={priority}
onChange={(event) => setPriority(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="editRuleLevel1">一级审批角色</Label>
<select
id="editRuleLevel1"
data-testid="approval-rule-edit-level1"
className="select-std"
value={level1Role}
onChange={(event) => setLevel1Role(event.target.value as ApprovalRoleCode)}
>
{ROLE_OPTIONS.map((role) => (
<option key={role.code} value={role.code}>
{role.label}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="editRuleLevel2">二级审批角色(可空)</Label>
<select
id="editRuleLevel2"
data-testid="approval-rule-edit-level2"
className="select-std"
value={level2Role}
onChange={(event) => setLevel2Role(event.target.value as ApprovalRoleCode)}
>
<option value="">无</option>
{ROLE_OPTIONS.map((role) => (
<option key={role.code} value={role.code}>
{role.label}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="editRuleLevel3">三级审批角色(可空)</Label>
<select
id="editRuleLevel3"
data-testid="approval-rule-edit-level3"
className="select-std"
value={level3Role}
onChange={(event) => setLevel3Role(event.target.value as ApprovalRoleCode)}
>
<option value="">无</option>
{ROLE_OPTIONS.map((role) => (
<option key={role.code} value={role.code}>
{role.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2 pt-6">
<input
id="editRuleEnabled"
data-testid="approval-rule-edit-enabled"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
type="checkbox"
/>
<Label htmlFor="editRuleEnabled">启用</Label>
</div>
</div>
<DialogFooter>
<Button
data-testid="approval-rule-edit-save"
type="button"
disabled={editMutation.isPending}
onClick={() => editMutation.mutate()}
>
{editMutation.isPending ? "保存中..." : "保存"}
</Button>
<Button
data-testid="approval-rule-edit-cancel"
type="button"
variant="secondary"
disabled={editMutation.isPending}
onClick={() => setEditOpen(false)}
>
取消
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}