feat(G2-4): 升级预算页为多作用域模式

这个提交包含在:
2026-03-07 18:59:58 +08:00
父节点 52558f22c3
当前提交 0259aa45c3
共修改 2 个文件,包含 232 行新增和 36 行删除
+32 -6
查看文件
@@ -1,10 +1,30 @@
import { apiRequestData } from "./http"; import { apiRequestData } from "./http";
export type BudgetScopeType = "global" | "department_legacy" | "cost_center_scoped" | "invalid";
export type BudgetPool = { export type BudgetPool = {
id: string; id: string;
code: string; code: string;
name: string; name: string;
scopeType: BudgetScopeType;
departmentId: string | null; 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"; status: "active" | "archived";
periodStart: string | null; periodStart: string | null;
periodEnd: string | null; periodEnd: string | null;
@@ -27,28 +47,34 @@ export type BudgetListResponse = {
export type BudgetListQuery = { export type BudgetListQuery = {
status?: "active" | "archived"; status?: "active" | "archived";
departmentId?: string; departmentId?: string;
legalEntityId?: string;
costCenterId?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
}; };
function toQueryString(query: BudgetListQuery = {}) { function toQueryString(query: Record<string, string | number | undefined>) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (query.status) params.set("status", query.status); for (const [key, value] of Object.entries(query)) {
if (query.departmentId) params.set("departmentId", query.departmentId); if (value == null || value === "") {
if (query.page != null) params.set("page", String(query.page)); continue;
if (query.pageSize != null) params.set("pageSize", String(query.pageSize)); }
params.set(key, String(value));
}
const encoded = params.toString(); const encoded = params.toString();
return encoded ? `?${encoded}` : ""; return encoded ? `?${encoded}` : "";
} }
export async function fetchBudgets(query: BudgetListQuery = {}) { export async function fetchBudgets(query: BudgetListQuery = {}) {
return apiRequestData<BudgetListResponse>(`/api/budgets${toQueryString(query)}`); return apiRequestData<BudgetListResponse>(`/api/budgets${toQueryString(query as Record<string, string | number | undefined>)}`);
} }
export async function createBudget(payload: { export async function createBudget(payload: {
code: string; code: string;
name: string; name: string;
departmentId?: string | null; departmentId?: string | null;
legalEntityId?: string | null;
costCenterId?: string | null;
periodStart: string; periodStart: string;
periodEnd: string; periodEnd: string;
allocatedAmount: string; allocatedAmount: string;
+200 -30
查看文件
@@ -1,8 +1,9 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages"; 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 { fetchDepartments } from "../api/departments";
import { fetchCostCenters, fetchLegalEntities } from "../api/masterData";
import { isApiError } from "../api/types"; import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions"; import { hasPermission } from "../auth/permissions";
@@ -17,6 +18,8 @@ import { Pagination } from "../components/common/Pagination";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
type FormScopeType = Exclude<BudgetScopeType, "invalid">;
function resolveError(error: unknown) { function resolveError(error: unknown) {
if (isApiError(error)) { if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
@@ -27,6 +30,24 @@ function resolveError(error: unknown) {
return "请求失败,请稍后重试。"; 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() { export function BudgetsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { permissions } = useAuth(); const { permissions } = useAuth();
@@ -36,8 +57,11 @@ export function BudgetsPage() {
const [status, setStatus] = useState<"active" | "archived" | "">("active"); const [status, setStatus] = useState<"active" | "archived" | "">("active");
const [departmentId, setDepartmentId] = useState(""); const [departmentId, setDepartmentId] = useState("");
const [legalEntityId, setLegalEntityId] = useState("");
const [costCenterId, setCostCenterId] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [formScopeType, setFormScopeType] = useState<FormScopeType>("global");
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [periodStart, setPeriodStart] = useState(""); const [periodStart, setPeriodStart] = useState("");
@@ -45,6 +69,8 @@ export function BudgetsPage() {
const [allocatedAmount, setAllocatedAmount] = useState(""); const [allocatedAmount, setAllocatedAmount] = useState("");
const [warningThresholdPercent, setWarningThresholdPercent] = useState("80"); const [warningThresholdPercent, setWarningThresholdPercent] = useState("80");
const [formDepartmentId, setFormDepartmentId] = useState(""); const [formDepartmentId, setFormDepartmentId] = useState("");
const [formLegalEntityId, setFormLegalEntityId] = useState("");
const [formCostCenterId, setFormCostCenterId] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [confirmArchiveId, setConfirmArchiveId] = useState<{ id: string; code: string } | null>(null); const [confirmArchiveId, setConfirmArchiveId] = useState<{ id: string; code: string } | null>(null);
@@ -52,10 +78,12 @@ export function BudgetsPage() {
() => ({ () => ({
status: status || undefined, status: status || undefined,
departmentId: departmentId || undefined, departmentId: departmentId || undefined,
legalEntityId: legalEntityId || undefined,
costCenterId: costCenterId || undefined,
page, page,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
}), }),
[status, departmentId, page], [status, departmentId, legalEntityId, costCenterId, page],
); );
const departmentsQuery = useQuery({ const departmentsQuery = useQuery({
@@ -64,18 +92,46 @@ export function BudgetsPage() {
queryFn: () => fetchDepartments(), 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({ const budgetsQuery = useQuery({
enabled: canViewBudget, enabled: canViewBudget,
queryKey: ["budgets", queryInput], queryKey: ["budgets", queryInput],
queryFn: () => fetchBudgets(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({ const createMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
return createBudget({ return createBudget({
code: code.trim().toUpperCase(), code: code.trim().toUpperCase(),
name: name.trim(), 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, periodStart,
periodEnd, periodEnd,
allocatedAmount: allocatedAmount.trim(), allocatedAmount: allocatedAmount.trim(),
@@ -91,6 +147,9 @@ export function BudgetsPage() {
setAllocatedAmount(""); setAllocatedAmount("");
setWarningThresholdPercent("80"); setWarningThresholdPercent("80");
setFormDepartmentId(""); setFormDepartmentId("");
setFormLegalEntityId("");
setFormCostCenterId("");
setFormScopeType("global");
setError(""); setError("");
toast.success("预算池已创建"); toast.success("预算池已创建");
}, },
@@ -123,12 +182,21 @@ export function BudgetsPage() {
} }
const totalPages = Math.max(1, Math.ceil((budgetsQuery.data?.total ?? 0) / PAGE_SIZE)); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="预算管理" title="预算管理"
subtitle="支持预算池维护、状态筛选与归档。" subtitle="支持全局预算、部门预算与法体/成本中心预算的统一维护。"
/> />
{error ? ( {error ? (
@@ -139,7 +207,7 @@ export function BudgetsPage() {
<div className="card-std p-6 space-y-3"> <div className="card-std p-6 space-y-3">
<h3 className="text-base font-semibold text-zinc-900">筛选条件</h3> <h3 className="text-base font-semibold text-zinc-900">筛选条件</h3>
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-3 md:grid-cols-4">
<select <select
className="select-std" className="select-std"
value={status} value={status}
@@ -161,7 +229,7 @@ export function BudgetsPage() {
setPage(1); setPage(1);
}} }}
> >
<option value="">全部部门</option> <option value="">全部部门预算</option>
{(departmentsQuery.data?.items ?? []).map((department) => ( {(departmentsQuery.data?.items ?? []).map((department) => (
<option key={department.id} value={department.id}> <option key={department.id} value={department.id}>
{department.name} {department.name}
@@ -169,6 +237,44 @@ export function BudgetsPage() {
))} ))}
</select> </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 <Button
type="button" type="button"
variant="secondary" variant="secondary"
@@ -176,6 +282,19 @@ export function BudgetsPage() {
> >
刷新列表 刷新列表
</Button> </Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setStatus("active");
setDepartmentId("");
setLegalEntityId("");
setCostCenterId("");
setPage(1);
}}
>
重置筛选
</Button>
</div> </div>
</div> </div>
@@ -197,16 +316,68 @@ export function BudgetsPage() {
/> />
<select <select
className="select-std" className="select-std"
value={formDepartmentId} value={formScopeType}
onChange={(event) => setFormDepartmentId(event.target.value)} onChange={(event) => {
const nextScopeType = event.target.value as FormScopeType;
setFormScopeType(nextScopeType);
setFormDepartmentId("");
setFormLegalEntityId("");
setFormCostCenterId("");
}}
> >
<option value="">全局预算(不限定部门)</option> <option value="global">全局预算</option>
{(departmentsQuery.data?.items ?? []).map((department) => ( <option value="department_legacy">部门预算</option>
<option key={department.id} value={department.id}> <option value="cost_center_scoped">法体 + 成本中心预算</option>
{department.name}
</option>
))}
</select> </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 <input
className="input-std" className="input-std"
type="date" type="date"
@@ -235,18 +406,7 @@ export function BudgetsPage() {
onChange={(event) => setWarningThresholdPercent(event.target.value)} onChange={(event) => setWarningThresholdPercent(event.target.value)}
/> />
</div> </div>
<Button <Button type="button" disabled={createDisabled} onClick={() => createMutation.mutate()}>
type="button"
disabled={
createMutation.isPending ||
!code.trim() ||
!name.trim() ||
!periodStart ||
!periodEnd ||
!allocatedAmount.trim()
}
onClick={() => createMutation.mutate()}
>
{createMutation.isPending ? "创建中..." : "创建预算池"} {createMutation.isPending ? "创建中..." : "创建预算池"}
</Button> </Button>
</div> </div>
@@ -255,7 +415,7 @@ export function BudgetsPage() {
<div className="card-std p-6 space-y-3"> <div className="card-std p-6 space-y-3">
<h3 className="text-base font-semibold text-zinc-900">预算池列表</h3> <h3 className="text-base font-semibold text-zinc-900">预算池列表</h3>
{budgetsQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null} {budgetsQuery.isLoading ? <TableSkeleton columns={8} rows={4} /> : null}
{!budgetsQuery.isLoading ? ( {!budgetsQuery.isLoading ? (
<> <>
@@ -265,6 +425,7 @@ export function BudgetsPage() {
<TableRow> <TableRow>
<TableHead>编码</TableHead> <TableHead>编码</TableHead>
<TableHead>名称</TableHead> <TableHead>名称</TableHead>
<TableHead>作用域</TableHead>
<TableHead>状态</TableHead> <TableHead>状态</TableHead>
<TableHead>期间</TableHead> <TableHead>期间</TableHead>
<TableHead>额度</TableHead> <TableHead>额度</TableHead>
@@ -278,6 +439,9 @@ export function BudgetsPage() {
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell className="font-medium text-zinc-900">{item.code}</TableCell> <TableCell className="font-medium text-zinc-900">{item.code}</TableCell>
<TableCell>{item.name}</TableCell> <TableCell>{item.name}</TableCell>
<TableCell>
<div className="text-sm text-zinc-700">{describeScope(item)}</div>
</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell> <TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell> <TableCell>
{(item.periodStart ?? "-")} ~ {(item.periodEnd ?? "-")} {(item.periodStart ?? "-")} ~ {(item.periodEnd ?? "-")}
@@ -306,7 +470,7 @@ export function BudgetsPage() {
)) ))
) : ( ) : (
<TableRow> <TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={7}> <TableCell className="py-3 text-zinc-500" colSpan={8}>
暂无预算池 暂无预算池
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -326,9 +490,15 @@ export function BudgetsPage() {
description={`确认归档预算池 ${confirmArchiveId?.code ?? ""} ?`} description={`确认归档预算池 ${confirmArchiveId?.code ?? ""} ?`}
variant="destructive" variant="destructive"
onConfirm={async () => { 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); }}
/> />
</div> </div>
); );