文件
hyb-oa/client/src/pages/BudgetsPage.tsx
T
wangsli 6dee2daf3e P25L-1~P25L-5 实现预算控制与AP最小可用运营
合同 CSV 已回填 reviewer/QA 结果;运行时回归受当前环境缺少 node/npm 阻塞。
2026-03-29 09:21:17 +08:00

936 行
37 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { archiveBudget, createBudget, fetchBudgets, type BudgetListQuery, type BudgetPool, type BudgetScopeType } from "../api/budgets";
import {
archiveBudgetControlRule,
createBudgetControlRule,
fetchBudgetControlRules,
patchBudgetControlRule,
type BudgetControlMode,
type BudgetControlRule,
type BudgetControlRuleEntityType,
} from "../api/budgetControlRules";
import { fetchDepartments } from "../api/departments";
import { fetchCostCenters, fetchLegalEntities } from "../api/masterData";
import { fetchExpenseTypes } from "../api/expenseTypes";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { toast } from "sonner";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { StatusBadge } from "../components/common/StatusBadge";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { Pagination } from "../components/common/Pagination";
const PAGE_SIZE = 20;
type FormScopeType = Exclude<BudgetScopeType, "invalid">;
type BudgetPageTab = "budget-pools" | "control-rules";
type RuleFormState = {
name: string;
entityType: BudgetControlRuleEntityType;
legalEntityId: string;
departmentId: string;
expenseTypeId: string;
controlMode: BudgetControlMode;
priority: string;
enabled: "true" | "false";
notes: string;
};
function emptyRuleForm(): RuleFormState {
return {
name: "",
entityType: "all",
legalEntityId: "",
departmentId: "",
expenseTypeId: "",
controlMode: "warn",
priority: "100",
enabled: "true",
notes: "",
};
}
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
}
function describeScope(item: BudgetPool) {
if (item.scopeType === "department_legacy") {
return item.department ? `部门:${item.department.name}` : "部门预算";
}
if (item.scopeType === "cost_center_scoped") {
const legalEntityName = item.legalEntity?.name ?? "未知法体";
const costCenterName = item.costCenter?.name ?? "未知成本中心";
return `法体:${legalEntityName} / 成本中心:${costCenterName}`;
}
if (item.scopeType === "global") {
return "全局预算";
}
return "异常作用域";
}
function entityTypeLabel(value: BudgetControlRuleEntityType) {
if (value === "all") return "全部单据";
if (value === "reimbursement") return "报销";
if (value === "purchase_request") return "采购";
return "差旅";
}
function controlModeLabel(value: BudgetControlMode) {
if (value === "warn") return "预警";
if (value === "block") return "阻断";
return "人工复核";
}
export function BudgetsPage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const canViewBudget = hasPermission(permissions, "budget.view") || hasPermission(permissions, "budget.manage");
const canManageBudget = hasPermission(permissions, "budget.manage");
const [tab, setTab] = useState<BudgetPageTab>("budget-pools");
const [status, setStatus] = useState<"active" | "archived" | "">("active");
const [departmentId, setDepartmentId] = useState("");
const [legalEntityId, setLegalEntityId] = useState("");
const [costCenterId, setCostCenterId] = useState("");
const [page, setPage] = useState(1);
const [formScopeType, setFormScopeType] = useState<FormScopeType>("global");
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [periodStart, setPeriodStart] = useState("");
const [periodEnd, setPeriodEnd] = useState("");
const [allocatedAmount, setAllocatedAmount] = useState("");
const [warningThresholdPercent, setWarningThresholdPercent] = useState("80");
const [formDepartmentId, setFormDepartmentId] = useState("");
const [formLegalEntityId, setFormLegalEntityId] = useState("");
const [formCostCenterId, setFormCostCenterId] = useState("");
const [error, setError] = useState("");
const [confirmArchiveId, setConfirmArchiveId] = useState<{ id: string; code: string } | null>(null);
const [ruleEntityTypeFilter, setRuleEntityTypeFilter] = useState<BudgetControlRuleEntityType | "">("");
const [ruleEnabledFilter, setRuleEnabledFilter] = useState<"" | "true" | "false">("true");
const [ruleForm, setRuleForm] = useState<RuleFormState>(emptyRuleForm);
const [editingRuleId, setEditingRuleId] = useState<string | null>(null);
const [confirmArchiveRuleId, setConfirmArchiveRuleId] = useState<{ id: string; name: string } | null>(null);
const queryInput = useMemo<BudgetListQuery>(
() => ({
status: status || undefined,
departmentId: departmentId || undefined,
legalEntityId: legalEntityId || undefined,
costCenterId: costCenterId || undefined,
page,
pageSize: PAGE_SIZE,
}),
[status, departmentId, legalEntityId, costCenterId, page],
);
const departmentsQuery = useQuery({
enabled: canViewBudget,
queryKey: ["departments", "budget-filter"],
queryFn: () => fetchDepartments(),
});
const legalEntitiesQuery = useQuery({
enabled: canViewBudget,
queryKey: ["legal-entities", "budget-filter"],
queryFn: () => fetchLegalEntities({ enabled: true }),
});
const costCentersQuery = useQuery({
enabled: canViewBudget,
queryKey: ["cost-centers", "budget-filter"],
queryFn: () => fetchCostCenters({ enabled: true }),
});
const budgetsQuery = useQuery({
enabled: canViewBudget,
queryKey: ["budgets", queryInput],
queryFn: () => fetchBudgets(queryInput),
});
const expenseTypesQuery = useQuery({
enabled: canManageBudget,
queryKey: ["expense-types", "budget-rules"],
queryFn: () => fetchExpenseTypes({ enabled: true }),
});
const rulesQuery = useQuery({
enabled: canManageBudget,
queryKey: ["budget-control-rules", ruleEntityTypeFilter, ruleEnabledFilter],
queryFn: () =>
fetchBudgetControlRules({
entityType: ruleEntityTypeFilter || undefined,
enabled: ruleEnabledFilter === "" ? undefined : ruleEnabledFilter === "true",
}),
});
const filterCostCenters = useMemo(() => {
if (!legalEntityId) {
return costCentersQuery.data?.items ?? [];
}
return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === legalEntityId);
}, [costCentersQuery.data?.items, legalEntityId]);
const formCostCenters = useMemo(() => {
if (!formLegalEntityId) {
return [];
}
return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === formLegalEntityId);
}, [costCentersQuery.data?.items, formLegalEntityId]);
const departmentMap = useMemo(
() => new Map((departmentsQuery.data?.items ?? []).map((item) => [item.id, item.name])),
[departmentsQuery.data?.items],
);
const legalEntityMap = useMemo(
() => new Map((legalEntitiesQuery.data?.items ?? []).map((item) => [item.id, item.name])),
[legalEntitiesQuery.data?.items],
);
const expenseTypeMap = useMemo(
() => new Map((expenseTypesQuery.data?.items ?? []).map((item) => [item.id, item.name])),
[expenseTypesQuery.data?.items],
);
const createMutation = useMutation({
mutationFn: async () => {
return createBudget({
code: code.trim().toUpperCase(),
name: name.trim(),
departmentId: formScopeType === "department_legacy" ? formDepartmentId || null : null,
legalEntityId: formScopeType === "cost_center_scoped" ? formLegalEntityId || null : null,
costCenterId: formScopeType === "cost_center_scoped" ? formCostCenterId || null : null,
periodStart,
periodEnd,
allocatedAmount: allocatedAmount.trim(),
warningThresholdPercent: Number(warningThresholdPercent || 80),
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["budgets"] });
setCode("");
setName("");
setPeriodStart("");
setPeriodEnd("");
setAllocatedAmount("");
setWarningThresholdPercent("80");
setFormDepartmentId("");
setFormLegalEntityId("");
setFormCostCenterId("");
setFormScopeType("global");
setError("");
toast.success("预算池已创建");
},
onError: (mutationError) => {
setError(resolveError(mutationError));
toast.error(resolveError(mutationError));
},
});
const archiveMutation = useMutation({
mutationFn: async (budgetId: string) => archiveBudget(budgetId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["budgets"] });
setError("");
toast.success("已归档");
},
onError: (mutationError) => {
setError(resolveError(mutationError));
toast.error(resolveError(mutationError));
},
});
const saveRuleMutation = useMutation({
mutationFn: async () => {
const payload = {
name: ruleForm.name.trim(),
entityType: ruleForm.entityType,
legalEntityId: ruleForm.legalEntityId || null,
departmentId: ruleForm.departmentId || null,
expenseTypeId: ruleForm.entityType === "reimbursement" ? ruleForm.expenseTypeId || null : null,
controlMode: ruleForm.controlMode,
priority: Number(ruleForm.priority || 100),
enabled: ruleForm.enabled === "true",
notes: ruleForm.notes.trim() || null,
};
if (editingRuleId) {
return patchBudgetControlRule(editingRuleId, payload);
}
return createBudgetControlRule(payload);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["budget-control-rules"] });
setRuleForm(emptyRuleForm());
setEditingRuleId(null);
setError("");
toast.success(editingRuleId ? "控制规则已更新" : "控制规则已创建");
},
onError: (mutationError) => {
setError(resolveError(mutationError));
toast.error(resolveError(mutationError));
},
});
const archiveRuleMutation = useMutation({
mutationFn: async (ruleId: string) => archiveBudgetControlRule(ruleId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["budget-control-rules"] });
setEditingRuleId(null);
setRuleForm(emptyRuleForm());
setError("");
toast.success("控制规则已归档");
},
onError: (mutationError) => {
setError(resolveError(mutationError));
toast.error(resolveError(mutationError));
},
});
if (!canViewBudget) {
return (
<div className="space-y-4">
<PageHeader title="预算管理" subtitle="你当前没有预算权限,请联系管理员开通。" />
<div className="card-std p-6 text-sm text-zinc-600">所需权限:budget.view 或 budget.manage</div>
</div>
);
}
const totalPages = Math.max(1, Math.ceil((budgetsQuery.data?.total ?? 0) / PAGE_SIZE));
const createDisabled =
createMutation.isPending ||
!code.trim() ||
!name.trim() ||
!periodStart ||
!periodEnd ||
!allocatedAmount.trim() ||
(formScopeType === "department_legacy" && !formDepartmentId) ||
(formScopeType === "cost_center_scoped" && (!formLegalEntityId || !formCostCenterId));
const ruleSaveDisabled =
saveRuleMutation.isPending ||
!ruleForm.name.trim() ||
!ruleForm.priority.trim();
const rules = rulesQuery.data?.items ?? [];
function resetRuleEditor() {
setEditingRuleId(null);
setRuleForm(emptyRuleForm());
}
function describeRuleScope(rule: BudgetControlRule) {
const parts: string[] = [];
if (rule.legalEntityId) {
parts.push(`法体:${legalEntityMap.get(rule.legalEntityId) ?? rule.legalEntityId}`);
}
if (rule.departmentId) {
parts.push(`部门:${departmentMap.get(rule.departmentId) ?? rule.departmentId}`);
}
if (rule.expenseTypeId) {
parts.push(`费用类型:${expenseTypeMap.get(rule.expenseTypeId) ?? rule.expenseTypeId}`);
}
if (rule.costCenterId) {
parts.push("成本中心:遗留规则");
}
return parts.length ? parts.join(" / ") : "全局";
}
function startEditRule(rule: BudgetControlRule) {
if (rule.costCenterId) {
toast.error("遗留成本中心规则仅支持归档,不在本轮编辑范围内。");
return;
}
setEditingRuleId(rule.id);
setRuleForm({
name: rule.name,
entityType: rule.entityType,
legalEntityId: rule.legalEntityId ?? "",
departmentId: rule.departmentId ?? "",
expenseTypeId: rule.expenseTypeId ?? "",
controlMode: rule.controlMode,
priority: String(rule.priority),
enabled: rule.enabled ? "true" : "false",
notes: rule.notes ?? "",
});
setTab("control-rules");
}
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}
<Tabs value={tab} onValueChange={(value) => setTab(value as BudgetPageTab)}>
<TabsList className="flex w-full flex-wrap justify-start gap-1">
<TabsTrigger value="budget-pools">预算池</TabsTrigger>
{canManageBudget ? <TabsTrigger value="control-rules">控制规则</TabsTrigger> : null}
</TabsList>
<TabsContent value="budget-pools" className="space-y-6">
<div className="card-std p-6 space-y-3">
<h3 className="text-base font-semibold text-zinc-900">筛选条件</h3>
<div className="grid gap-3 md:grid-cols-4">
<select
className="select-std"
value={status}
onChange={(event) => {
setStatus(event.target.value as "active" | "archived" | "");
setPage(1);
}}
>
<option value="">全部状态</option>
<option value="active">生效中</option>
<option value="archived">已归档</option>
</select>
<select
className="select-std"
value={departmentId}
onChange={(event) => {
setDepartmentId(event.target.value);
setPage(1);
}}
>
<option value="">全部部门预算</option>
{(departmentsQuery.data?.items ?? []).map((department) => (
<option key={department.id} value={department.id}>
{department.name}
</option>
))}
</select>
<select
className="select-std"
value={legalEntityId}
onChange={(event) => {
const nextLegalEntityId = event.target.value;
setLegalEntityId(nextLegalEntityId);
if (costCenterId && !filterCostCenters.some((item) => item.id === costCenterId && item.legalEntityId === nextLegalEntityId)) {
setCostCenterId("");
}
setPage(1);
}}
>
<option value="">全部法体</option>
{(legalEntitiesQuery.data?.items ?? []).map((legalEntity) => (
<option key={legalEntity.id} value={legalEntity.id}>
{legalEntity.name}
</option>
))}
</select>
<select
className="select-std"
value={costCenterId}
onChange={(event) => {
setCostCenterId(event.target.value);
setPage(1);
}}
>
<option value="">全部成本中心</option>
{filterCostCenters.map((costCenter) => (
<option key={costCenter.id} value={costCenter.id}>
{costCenter.name}
</option>
))}
</select>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
onClick={() => queryClient.invalidateQueries({ queryKey: ["budgets"] })}
>
刷新列表
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setStatus("active");
setDepartmentId("");
setLegalEntityId("");
setCostCenterId("");
setPage(1);
}}
>
重置筛选
</Button>
</div>
</div>
{canManageBudget ? (
<div className="card-std p-6 space-y-3">
<h3 className="text-base font-semibold text-zinc-900">新建预算池</h3>
<div className="grid gap-3 md:grid-cols-3">
<input
className="input-std"
placeholder="编码(例如 BGT-2026-RD)"
value={code}
onChange={(event) => setCode(event.target.value)}
/>
<input
className="input-std"
placeholder="名称"
value={name}
onChange={(event) => setName(event.target.value)}
/>
<select
className="select-std"
value={formScopeType}
onChange={(event) => {
const nextScopeType = event.target.value as FormScopeType;
setFormScopeType(nextScopeType);
setFormDepartmentId("");
setFormLegalEntityId("");
setFormCostCenterId("");
}}
>
<option value="global">全局预算</option>
<option value="department_legacy">部门预算</option>
<option value="cost_center_scoped">法体 + 成本中心预算</option>
</select>
{formScopeType === "department_legacy" ? (
<select
className="select-std"
value={formDepartmentId}
onChange={(event) => setFormDepartmentId(event.target.value)}
>
<option value="">选择部门</option>
{(departmentsQuery.data?.items ?? []).map((department) => (
<option key={department.id} value={department.id}>
{department.name}
</option>
))}
</select>
) : null}
{formScopeType === "cost_center_scoped" ? (
<>
<select
className="select-std"
value={formLegalEntityId}
onChange={(event) => {
setFormLegalEntityId(event.target.value);
setFormCostCenterId("");
}}
>
<option value="">选择法体</option>
{(legalEntitiesQuery.data?.items ?? []).map((legalEntity) => (
<option key={legalEntity.id} value={legalEntity.id}>
{legalEntity.name}
</option>
))}
</select>
<select
className="select-std"
value={formCostCenterId}
onChange={(event) => setFormCostCenterId(event.target.value)}
disabled={!formLegalEntityId}
>
<option value="">选择成本中心</option>
{formCostCenters.map((costCenter) => (
<option key={costCenter.id} value={costCenter.id}>
{costCenter.name}
</option>
))}
</select>
</>
) : null}
<input
className="input-std"
type="date"
value={periodStart}
onChange={(event) => setPeriodStart(event.target.value)}
/>
<input
className="input-std"
type="date"
value={periodEnd}
onChange={(event) => setPeriodEnd(event.target.value)}
/>
<input
className="input-std"
placeholder="预算总额(例如 100000.00)"
value={allocatedAmount}
onChange={(event) => setAllocatedAmount(event.target.value)}
/>
<input
className="input-std"
type="number"
min={1}
max={100}
placeholder="预警阈值(%)"
value={warningThresholdPercent}
onChange={(event) => setWarningThresholdPercent(event.target.value)}
/>
</div>
<Button type="button" disabled={createDisabled} onClick={() => createMutation.mutate()}>
{createMutation.isPending ? "创建中..." : "创建预算池"}
</Button>
</div>
) : null}
<div className="card-std p-6 space-y-3">
<h3 className="text-base font-semibold text-zinc-900">预算池列表</h3>
{budgetsQuery.isLoading ? <TableSkeleton columns={8} rows={4} /> : null}
{!budgetsQuery.isLoading ? (
<>
<div className="rounded-lg border border-zinc-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>编码</TableHead>
<TableHead>名称</TableHead>
<TableHead>作用域</TableHead>
<TableHead>状态</TableHead>
<TableHead>期间</TableHead>
<TableHead>额度</TableHead>
<TableHead>预警阈值</TableHead>
<TableHead className="w-28">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(budgetsQuery.data?.items ?? []).length ? (
(budgetsQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium text-zinc-900">{item.code}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>
<div className="text-sm text-zinc-700">{describeScope(item)}</div>
</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell>
{(item.periodStart ?? "-")} ~ {(item.periodEnd ?? "-")}
</TableCell>
<TableCell>
{item.usedAmount} / {item.allocatedAmount}
<div className="text-xs text-zinc-500">剩余 {item.remainingAmount}</div>
</TableCell>
<TableCell>{item.warningThresholdPercent}%</TableCell>
<TableCell>
{canManageBudget && item.status !== "archived" ? (
<Button
size="sm"
type="button"
variant="secondary"
disabled={archiveMutation.isPending}
onClick={() => setConfirmArchiveId({ id: item.id, code: item.code })}
>
归档
</Button>
) : (
<span className="text-xs text-zinc-400">-</span>
)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={8}>
暂无预算池
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</>
) : null}
</div>
</TabsContent>
{canManageBudget ? (
<TabsContent value="control-rules" className="space-y-6">
<div className="card-std p-6 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">规则筛选</h3>
<div className="text-sm text-zinc-500">费用类型仅对报销规则生效;遗留成本中心规则本轮只归档不编辑。</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
<select className="select-std" value={ruleEntityTypeFilter} onChange={(event) => setRuleEntityTypeFilter(event.target.value as BudgetControlRuleEntityType | "")}>
<option value="">全部业务域</option>
<option value="all">全部单据</option>
<option value="reimbursement">报销</option>
<option value="purchase_request">采购</option>
<option value="travel_request">差旅</option>
</select>
<select className="select-std" value={ruleEnabledFilter} onChange={(event) => setRuleEnabledFilter(event.target.value as "" | "true" | "false")}>
<option value="">全部状态</option>
<option value="true">仅启用</option>
<option value="false">仅停用</option>
</select>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" onClick={() => queryClient.invalidateQueries({ queryKey: ["budget-control-rules"] })}>
刷新规则
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setRuleEntityTypeFilter("");
setRuleEnabledFilter("true");
}}
>
重置筛选
</Button>
</div>
</div>
</div>
<div className="card-std p-6 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">{editingRuleId ? "编辑控制规则" : "新建控制规则"}</h3>
{editingRuleId ? (
<Button type="button" variant="ghost" onClick={resetRuleEditor}>
取消编辑
</Button>
) : null}
</div>
<div className="grid gap-3 md:grid-cols-3">
<input
className="input-std"
placeholder="规则名称"
value={ruleForm.name}
onChange={(event) => setRuleForm((current) => ({ ...current, name: event.target.value }))}
/>
<select
className="select-std"
value={ruleForm.entityType}
onChange={(event) => {
const nextEntityType = event.target.value as BudgetControlRuleEntityType;
setRuleForm((current) => ({
...current,
entityType: nextEntityType,
expenseTypeId: nextEntityType === "reimbursement" ? current.expenseTypeId : "",
}));
}}
>
<option value="all">全部单据</option>
<option value="reimbursement">报销</option>
<option value="purchase_request">采购</option>
<option value="travel_request">差旅</option>
</select>
<select
className="select-std"
value={ruleForm.controlMode}
onChange={(event) => setRuleForm((current) => ({ ...current, controlMode: event.target.value as BudgetControlMode }))}
>
<option value="warn">预警</option>
<option value="block">阻断</option>
<option value="manual_review">人工复核</option>
</select>
<select
className="select-std"
value={ruleForm.legalEntityId}
onChange={(event) => setRuleForm((current) => ({ ...current, legalEntityId: event.target.value }))}
>
<option value="">不限法体</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<select
className="select-std"
value={ruleForm.departmentId}
onChange={(event) => setRuleForm((current) => ({ ...current, departmentId: event.target.value }))}
>
<option value="">不限部门</option>
{(departmentsQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<select
className="select-std"
value={ruleForm.expenseTypeId}
disabled={ruleForm.entityType !== "reimbursement"}
onChange={(event) => setRuleForm((current) => ({ ...current, expenseTypeId: event.target.value }))}
>
<option value="">{ruleForm.entityType === "reimbursement" ? "不限费用类型" : "仅报销规则可选"}</option>
{(expenseTypesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<input
className="input-std"
type="number"
min={1}
max={9999}
placeholder="优先级"
value={ruleForm.priority}
onChange={(event) => setRuleForm((current) => ({ ...current, priority: event.target.value }))}
/>
<select
className="select-std"
value={ruleForm.enabled}
onChange={(event) => setRuleForm((current) => ({ ...current, enabled: event.target.value as "true" | "false" }))}
>
<option value="true">启用</option>
<option value="false">停用</option>
</select>
<input
className="input-std"
placeholder="备注(可选)"
value={ruleForm.notes}
onChange={(event) => setRuleForm((current) => ({ ...current, notes: event.target.value }))}
/>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" disabled={ruleSaveDisabled} onClick={() => saveRuleMutation.mutate()}>
{saveRuleMutation.isPending ? "保存中..." : editingRuleId ? "保存规则" : "创建规则"}
</Button>
<Button type="button" variant="ghost" onClick={resetRuleEditor}>
重置表单
</Button>
</div>
</div>
<div className="card-std p-6 space-y-3">
<h3 className="text-base font-semibold text-zinc-900">控制规则列表</h3>
{rulesQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
{!rulesQuery.isLoading ? (
<div className="rounded-lg border border-zinc-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>规则名称</TableHead>
<TableHead>业务域</TableHead>
<TableHead>作用域</TableHead>
<TableHead>控制策略</TableHead>
<TableHead>优先级</TableHead>
<TableHead>状态</TableHead>
<TableHead className="w-40">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rules.length ? (
rules.map((rule) => (
<TableRow key={rule.id}>
<TableCell>
<div className="font-medium text-zinc-900">{rule.name}</div>
{rule.notes ? <div className="text-xs text-zinc-500">{rule.notes}</div> : null}
</TableCell>
<TableCell>{entityTypeLabel(rule.entityType)}</TableCell>
<TableCell>{describeRuleScope(rule)}</TableCell>
<TableCell>{controlModeLabel(rule.controlMode)}</TableCell>
<TableCell>{rule.priority}</TableCell>
<TableCell>{rule.enabled ? "启用" : "停用"}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
type="button"
variant="secondary"
disabled={Boolean(rule.costCenterId)}
onClick={() => startEditRule(rule)}
>
编辑
</Button>
<Button
size="sm"
type="button"
variant="secondary"
disabled={archiveRuleMutation.isPending}
onClick={() => setConfirmArchiveRuleId({ id: rule.id, name: rule.name })}
>
归档
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={7}>
暂无控制规则
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
) : null}
</div>
</TabsContent>
) : null}
</Tabs>
<ConfirmDialog
open={Boolean(confirmArchiveId)}
title="确认归档"
description={`确认归档预算池 ${confirmArchiveId?.code ?? ""} ?`}
variant="destructive"
onConfirm={async () => {
if (confirmArchiveId) {
archiveMutation.mutate(confirmArchiveId.id);
}
}}
onOpenChange={(open) => {
if (!open) {
setConfirmArchiveId(null);
}
}}
/>
<ConfirmDialog
open={Boolean(confirmArchiveRuleId)}
title="确认归档规则"
description={`确认归档控制规则 ${confirmArchiveRuleId?.name ?? ""} ?`}
variant="destructive"
onConfirm={async () => {
if (confirmArchiveRuleId) {
archiveRuleMutation.mutate(confirmArchiveRuleId.id);
}
}}
onOpenChange={(open) => {
if (!open) {
setConfirmArchiveRuleId(null);
}
}}
/>
</div>
);
}