517 行
19 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|