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; 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("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("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(""); const [ruleEnabledFilter, setRuleEnabledFilter] = useState<"" | "true" | "false">("true"); const [ruleForm, setRuleForm] = useState(emptyRuleForm); const [editingRuleId, setEditingRuleId] = useState(null); const [confirmArchiveRuleId, setConfirmArchiveRuleId] = useState<{ id: string; name: string } | null>(null); const queryInput = useMemo( () => ({ 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 (
所需权限:budget.view 或 budget.manage
); } 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 (
{error ? (

{error}

) : null} setTab(value as BudgetPageTab)}> 预算池 {canManageBudget ? 控制规则 : null}

筛选条件

{canManageBudget ? (

新建预算池

setCode(event.target.value)} /> setName(event.target.value)} /> {formScopeType === "department_legacy" ? ( ) : null} {formScopeType === "cost_center_scoped" ? ( <> ) : null} setPeriodStart(event.target.value)} /> setPeriodEnd(event.target.value)} /> setAllocatedAmount(event.target.value)} /> setWarningThresholdPercent(event.target.value)} />
) : null}

预算池列表

{budgetsQuery.isLoading ? : null} {!budgetsQuery.isLoading ? ( <>
编码 名称 作用域 状态 期间 额度 预警阈值 操作 {(budgetsQuery.data?.items ?? []).length ? ( (budgetsQuery.data?.items ?? []).map((item) => ( {item.code} {item.name}
{describeScope(item)}
{(item.periodStart ?? "-")} ~ {(item.periodEnd ?? "-")} {item.usedAmount} / {item.allocatedAmount}
剩余 {item.remainingAmount}
{item.warningThresholdPercent}% {canManageBudget && item.status !== "archived" ? ( ) : ( - )}
)) ) : ( 暂无预算池 )}
) : null}
{canManageBudget ? (

规则筛选

费用类型仅对报销规则生效;遗留成本中心规则本轮只归档不编辑。

{editingRuleId ? "编辑控制规则" : "新建控制规则"}

{editingRuleId ? ( ) : null}
setRuleForm((current) => ({ ...current, name: event.target.value }))} /> setRuleForm((current) => ({ ...current, priority: event.target.value }))} /> setRuleForm((current) => ({ ...current, notes: event.target.value }))} />

控制规则列表

{rulesQuery.isLoading ? : null} {!rulesQuery.isLoading ? (
规则名称 业务域 作用域 控制策略 优先级 状态 操作 {rules.length ? ( rules.map((rule) => (
{rule.name}
{rule.notes ?
{rule.notes}
: null}
{entityTypeLabel(rule.entityType)} {describeRuleScope(rule)} {controlModeLabel(rule.controlMode)} {rule.priority} {rule.enabled ? "启用" : "停用"}
)) ) : ( 暂无控制规则 )}
) : null}
) : null}
{ if (confirmArchiveId) { archiveMutation.mutate(confirmArchiveId.id); } }} onOpenChange={(open) => { if (!open) { setConfirmArchiveId(null); } }} /> { if (confirmArchiveRuleId) { archiveRuleMutation.mutate(confirmArchiveRuleId.id); } }} onOpenChange={(open) => { if (!open) { setConfirmArchiveRuleId(null); } }} />
); }