936 行
37 KiB
TypeScript
936 行
37 KiB
TypeScript
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>
|
||
);
|
||
}
|