feat: 完成P6-P8前端能力闭环(V3,D2,D3,B3)
这个提交包含在:
@@ -0,0 +1,334 @@
|
||||
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 { fetchDepartments } from "../api/departments";
|
||||
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 { Card } from "../components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
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 "请求失败,请稍后重试。";
|
||||
}
|
||||
|
||||
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 [status, setStatus] = useState<"active" | "archived" | "">("active");
|
||||
const [departmentId, setDepartmentId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
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 [error, setError] = useState("");
|
||||
|
||||
const queryInput = useMemo<BudgetListQuery>(
|
||||
() => ({
|
||||
status: status || undefined,
|
||||
departmentId: departmentId || undefined,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
}),
|
||||
[status, departmentId, page],
|
||||
);
|
||||
|
||||
const departmentsQuery = useQuery({
|
||||
enabled: canViewBudget,
|
||||
queryKey: ["departments", "budget-filter"],
|
||||
queryFn: () => fetchDepartments(),
|
||||
});
|
||||
|
||||
const budgetsQuery = useQuery({
|
||||
enabled: canViewBudget,
|
||||
queryKey: ["budgets", queryInput],
|
||||
queryFn: () => fetchBudgets(queryInput),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return createBudget({
|
||||
code: code.trim().toUpperCase(),
|
||||
name: name.trim(),
|
||||
departmentId: formDepartmentId || 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("");
|
||||
setError("");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setError(resolveError(mutationError));
|
||||
},
|
||||
});
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: async (budgetId: string) => archiveBudget(budgetId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["budgets"] });
|
||||
setError("");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setError(resolveError(mutationError));
|
||||
},
|
||||
});
|
||||
|
||||
if (!canViewBudget) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="预算管理" subtitle="你当前没有预算权限,请联系管理员开通。" />
|
||||
<Card className="p-6 text-sm text-slate-600">所需权限:budget.view 或 budget.manage</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((budgetsQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||
|
||||
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}
|
||||
|
||||
<Card className="space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900">筛选条件</h3>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<select
|
||||
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
value={status}
|
||||
onChange={(event) => {
|
||||
setStatus(event.target.value as "active" | "archived" | "");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">active</option>
|
||||
<option value="archived">archived</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
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>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ["budgets"] })}
|
||||
>
|
||||
刷新列表
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{canManageBudget ? (
|
||||
<Card className="space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900">新建预算池</h3>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<input
|
||||
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
placeholder="编码(例如 BGT-2026-RD)"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
placeholder="名称"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
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>
|
||||
<input
|
||||
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
type="date"
|
||||
value={periodStart}
|
||||
onChange={(event) => setPeriodStart(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
type="date"
|
||||
value={periodEnd}
|
||||
onChange={(event) => setPeriodEnd(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
placeholder="预算总额(例如 100000.00)"
|
||||
value={allocatedAmount}
|
||||
onChange={(event) => setAllocatedAmount(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
placeholder="预警阈值(%)"
|
||||
value={warningThresholdPercent}
|
||||
onChange={(event) => setWarningThresholdPercent(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
createMutation.isPending ||
|
||||
!code.trim() ||
|
||||
!name.trim() ||
|
||||
!periodStart ||
|
||||
!periodEnd ||
|
||||
!allocatedAmount.trim()
|
||||
}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
{createMutation.isPending ? "创建中..." : "创建预算池"}
|
||||
</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900">预算池列表</h3>
|
||||
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<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-slate-900">{item.code}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>{item.status}</TableCell>
|
||||
<TableCell>
|
||||
{(item.periodStart ?? "-")} ~ {(item.periodEnd ?? "-")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.usedAmount} / {item.allocatedAmount}
|
||||
<div className="text-xs text-slate-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={() => {
|
||||
if (!window.confirm(`确认归档预算池 ${item.code} ?`)) return;
|
||||
archiveMutation.mutate(item.id);
|
||||
}}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="py-3 text-slate-500" colSpan={7}>
|
||||
暂无预算池
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户