From 0259aa45c355c59623f6c8f55e4f6d9c20bb0bca Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 7 Mar 2026 18:59:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(G2-4):=20=E5=8D=87=E7=BA=A7=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E9=A1=B5=E4=B8=BA=E5=A4=9A=E4=BD=9C=E7=94=A8=E5=9F=9F?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/budgets.ts | 38 ++++- client/src/pages/BudgetsPage.tsx | 230 +++++++++++++++++++++++++++---- 2 files changed, 232 insertions(+), 36 deletions(-) diff --git a/client/src/api/budgets.ts b/client/src/api/budgets.ts index 1b866110..2576672f 100644 --- a/client/src/api/budgets.ts +++ b/client/src/api/budgets.ts @@ -1,10 +1,30 @@ import { apiRequestData } from "./http"; +export type BudgetScopeType = "global" | "department_legacy" | "cost_center_scoped" | "invalid"; + export type BudgetPool = { id: string; code: string; name: string; + scopeType: BudgetScopeType; departmentId: string | null; + legalEntityId: string | null; + costCenterId: string | null; + department: { + id: string; + name: string; + } | null; + legalEntity: { + id: string; + code: string; + name: string; + } | null; + costCenter: { + id: string; + code: string; + name: string; + legalEntityId: string; + } | null; status: "active" | "archived"; periodStart: string | null; periodEnd: string | null; @@ -27,28 +47,34 @@ export type BudgetListResponse = { export type BudgetListQuery = { status?: "active" | "archived"; departmentId?: string; + legalEntityId?: string; + costCenterId?: string; page?: number; pageSize?: number; }; -function toQueryString(query: BudgetListQuery = {}) { +function toQueryString(query: Record) { const params = new URLSearchParams(); - if (query.status) params.set("status", query.status); - if (query.departmentId) params.set("departmentId", query.departmentId); - if (query.page != null) params.set("page", String(query.page)); - if (query.pageSize != null) params.set("pageSize", String(query.pageSize)); + for (const [key, value] of Object.entries(query)) { + if (value == null || value === "") { + continue; + } + params.set(key, String(value)); + } const encoded = params.toString(); return encoded ? `?${encoded}` : ""; } export async function fetchBudgets(query: BudgetListQuery = {}) { - return apiRequestData(`/api/budgets${toQueryString(query)}`); + return apiRequestData(`/api/budgets${toQueryString(query as Record)}`); } export async function createBudget(payload: { code: string; name: string; departmentId?: string | null; + legalEntityId?: string | null; + costCenterId?: string | null; periodStart: string; periodEnd: string; allocatedAmount: string; diff --git a/client/src/pages/BudgetsPage.tsx b/client/src/pages/BudgetsPage.tsx index 3b78ebbd..c42e46b1 100644 --- a/client/src/pages/BudgetsPage.tsx +++ b/client/src/pages/BudgetsPage.tsx @@ -1,8 +1,9 @@ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { mapApiErrorMessage } from "../api/errorMessages"; -import { createBudget, fetchBudgets, archiveBudget, type BudgetListQuery } from "../api/budgets"; +import { archiveBudget, createBudget, fetchBudgets, type BudgetListQuery, type BudgetPool, type BudgetScopeType } from "../api/budgets"; import { fetchDepartments } from "../api/departments"; +import { fetchCostCenters, fetchLegalEntities } from "../api/masterData"; import { isApiError } from "../api/types"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; @@ -17,6 +18,8 @@ import { Pagination } from "../components/common/Pagination"; const PAGE_SIZE = 20; +type FormScopeType = Exclude; + function resolveError(error: unknown) { if (isApiError(error)) { return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; @@ -27,6 +30,24 @@ function resolveError(error: unknown) { 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 "异常作用域"; +} + export function BudgetsPage() { const queryClient = useQueryClient(); const { permissions } = useAuth(); @@ -36,8 +57,11 @@ export function BudgetsPage() { 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(""); @@ -45,6 +69,8 @@ export function BudgetsPage() { 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); @@ -52,10 +78,12 @@ export function BudgetsPage() { () => ({ status: status || undefined, departmentId: departmentId || undefined, + legalEntityId: legalEntityId || undefined, + costCenterId: costCenterId || undefined, page, pageSize: PAGE_SIZE, }), - [status, departmentId, page], + [status, departmentId, legalEntityId, costCenterId, page], ); const departmentsQuery = useQuery({ @@ -64,18 +92,46 @@ export function BudgetsPage() { 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 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 createMutation = useMutation({ mutationFn: async () => { return createBudget({ code: code.trim().toUpperCase(), name: name.trim(), - departmentId: formDepartmentId || null, + 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(), @@ -91,6 +147,9 @@ export function BudgetsPage() { setAllocatedAmount(""); setWarningThresholdPercent("80"); setFormDepartmentId(""); + setFormLegalEntityId(""); + setFormCostCenterId(""); + setFormScopeType("global"); setError(""); toast.success("预算池已创建"); }, @@ -123,12 +182,21 @@ export function BudgetsPage() { } 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)); return (
{error ? ( @@ -139,7 +207,7 @@ export function BudgetsPage() {

筛选条件

-
+
+ + + +
+ +
+
@@ -197,16 +316,68 @@ export function BudgetsPage() { /> + + {formScopeType === "department_legacy" ? ( + + ) : null} + + {formScopeType === "cost_center_scoped" ? ( + <> + + + + ) : null} + setWarningThresholdPercent(event.target.value)} />
-
@@ -255,7 +415,7 @@ export function BudgetsPage() {

预算池列表

- {budgetsQuery.isLoading ? : null} + {budgetsQuery.isLoading ? : null} {!budgetsQuery.isLoading ? ( <> @@ -265,6 +425,7 @@ export function BudgetsPage() { 编码 名称 + 作用域 状态 期间 额度 @@ -278,6 +439,9 @@ export function BudgetsPage() { {item.code} {item.name} + +
{describeScope(item)}
+
{(item.periodStart ?? "-")} ~ {(item.periodEnd ?? "-")} @@ -306,7 +470,7 @@ export function BudgetsPage() { )) ) : ( - + 暂无预算池 @@ -326,9 +490,15 @@ export function BudgetsPage() { description={`确认归档预算池 ${confirmArchiveId?.code ?? ""} ?`} variant="destructive" onConfirm={async () => { - if (confirmArchiveId) archiveMutation.mutate(confirmArchiveId.id); + if (confirmArchiveId) { + archiveMutation.mutate(confirmArchiveId.id); + } + }} + onOpenChange={(open) => { + if (!open) { + setConfirmArchiveId(null); + } }} - onOpenChange={(open) => { if (!open) setConfirmArchiveId(null); }} />
);