feat: 完成P6-P8前端能力闭环(V3,D2,D3,B3)
这个提交包含在:
@@ -0,0 +1,83 @@
|
|||||||
|
import { apiRequestData } from "./http";
|
||||||
|
|
||||||
|
export type BudgetPool = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
departmentId: string | null;
|
||||||
|
status: "active" | "archived";
|
||||||
|
periodStart: string | null;
|
||||||
|
periodEnd: string | null;
|
||||||
|
allocatedAmount: string;
|
||||||
|
usedAmount: string;
|
||||||
|
remainingAmount: string;
|
||||||
|
warningThresholdPercent: number;
|
||||||
|
archivedAt: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BudgetListResponse = {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
items: BudgetPool[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BudgetListQuery = {
|
||||||
|
status?: "active" | "archived";
|
||||||
|
departmentId?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toQueryString(query: BudgetListQuery = {}) {
|
||||||
|
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));
|
||||||
|
const encoded = params.toString();
|
||||||
|
return encoded ? `?${encoded}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBudgets(query: BudgetListQuery = {}) {
|
||||||
|
return apiRequestData<BudgetListResponse>(`/api/budgets${toQueryString(query)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBudget(payload: {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
departmentId?: string | null;
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
allocatedAmount: string;
|
||||||
|
warningThresholdPercent?: number;
|
||||||
|
}) {
|
||||||
|
return apiRequestData<BudgetPool>(`/api/budgets`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBudget(
|
||||||
|
budgetId: string,
|
||||||
|
payload: Partial<{
|
||||||
|
name: string;
|
||||||
|
allocatedAmount: string;
|
||||||
|
warningThresholdPercent: number;
|
||||||
|
status: "active" | "archived";
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return apiRequestData<BudgetPool>(`/api/budgets/${budgetId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveBudget(budgetId: string) {
|
||||||
|
return apiRequestData<BudgetPool>(`/api/budgets/${budgetId}/archive`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { apiRequestData } from "./http";
|
||||||
|
|
||||||
|
export type DashboardQuery = {
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
departmentId?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toQueryString(query: DashboardQuery = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query.dateFrom) params.set("dateFrom", query.dateFrom);
|
||||||
|
if (query.dateTo) params.set("dateTo", query.dateTo);
|
||||||
|
if (query.departmentId) params.set("departmentId", query.departmentId);
|
||||||
|
if (query.limit != null) params.set("limit", String(query.limit));
|
||||||
|
const encoded = params.toString();
|
||||||
|
return encoded ? `?${encoded}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashboardExpenseOverview = {
|
||||||
|
totalCount: number;
|
||||||
|
totalAmount: string;
|
||||||
|
paidCount: number;
|
||||||
|
paidAmount: string;
|
||||||
|
reviewingCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardTrendPoint = {
|
||||||
|
date: string;
|
||||||
|
amount: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardTopCategory = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
amount: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardApprovalEfficiency = {
|
||||||
|
approvedCount: number;
|
||||||
|
averageHours: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchDashboardOverview(query: DashboardQuery = {}) {
|
||||||
|
return apiRequestData<DashboardExpenseOverview>(`/api/dashboard/expense-overview${toQueryString(query)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDashboardTrends(query: DashboardQuery = {}) {
|
||||||
|
return apiRequestData<{ points: DashboardTrendPoint[] }>(`/api/dashboard/trends${toQueryString(query)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDashboardTopCategories(query: DashboardQuery = {}) {
|
||||||
|
return apiRequestData<{ items: DashboardTopCategory[] }>(`/api/dashboard/top-categories${toQueryString(query)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDashboardApprovalEfficiency(query: DashboardQuery = {}) {
|
||||||
|
return apiRequestData<DashboardApprovalEfficiency>(`/api/dashboard/approval-efficiency${toQueryString(query)}`);
|
||||||
|
}
|
||||||
@@ -114,6 +114,35 @@ export function mapApiErrorMessage(
|
|||||||
if (details?.ocr === "disabled") {
|
if (details?.ocr === "disabled") {
|
||||||
return "当前环境未开启 OCR,请联系管理员或切换到 mock/tencent。";
|
return "当前环境未开启 OCR,请联系管理员或切换到 mock/tencent。";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (details?.verification === "disabled") {
|
||||||
|
return "当前环境未开启发票验真。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details?.verification === "already_pending") {
|
||||||
|
return "已有验真任务正在处理中,请稍后查看结果。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details?.verification === "too_frequent") {
|
||||||
|
return "验真触发过于频繁,请稍后重试。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details?.verification === "attempts_exceeded") {
|
||||||
|
return "验真重试次数已达上限,请联系财务人工核验。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details?.budget === "exceeded_block_mode") {
|
||||||
|
return "当前报销金额超出预算,系统已阻断提交。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details?.integration === "erp_sync_disabled") {
|
||||||
|
return "ERP 同步功能未开启。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details?.integration === "travel_sync_disabled") {
|
||||||
|
return "差旅同步功能未开启。";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallbackMessage || "请求失败,请稍后重试。";
|
return fallbackMessage || "请求失败,请稍后重试。";
|
||||||
|
|||||||
+15
-1
@@ -41,5 +41,19 @@ export async function exportAuditLogs(params: {
|
|||||||
return apiRequestBlob(`/api/exports/audit-logs?${qs.toString()}`);
|
return apiRequestBlob(`/api/exports/audit-logs?${qs.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { ApiBlobResponse };
|
export async function exportDashboardReimbursements(params: {
|
||||||
|
format: "csv" | "xlsx";
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
departmentId?: string;
|
||||||
|
}) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set("format", params.format);
|
||||||
|
if (params.dateFrom) qs.set("dateFrom", params.dateFrom);
|
||||||
|
if (params.dateTo) qs.set("dateTo", params.dateTo);
|
||||||
|
if (params.departmentId) qs.set("departmentId", params.departmentId);
|
||||||
|
|
||||||
|
return apiRequestBlob(`/api/exports/dashboard/reimbursements?${qs.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ApiBlobResponse };
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { apiRequestData } from "./http";
|
||||||
|
|
||||||
|
export async function triggerErpSync(reimbursementId: string, runNow = true) {
|
||||||
|
return apiRequestData<{
|
||||||
|
outboxId: string;
|
||||||
|
status: string;
|
||||||
|
duplicated: boolean;
|
||||||
|
}>(`/api/integrations/erp/sync/${reimbursementId}?runNow=${runNow ? "1" : "0"}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importTravelOrders(payload: {
|
||||||
|
orders: Array<{
|
||||||
|
source: string;
|
||||||
|
externalOrderNo: string;
|
||||||
|
employeeId: string;
|
||||||
|
linkedReimbursementId?: string;
|
||||||
|
occurredAt?: string;
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
return apiRequestData<{
|
||||||
|
requested: number;
|
||||||
|
imported: number;
|
||||||
|
skipped: Array<{ source: string; externalOrderNo: string; reason: string }>;
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
externalOrderNo: string;
|
||||||
|
employeeId: string;
|
||||||
|
linkedReimbursementId: string | null;
|
||||||
|
}>;
|
||||||
|
}>(`/api/travel/orders/import`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testNotificationChannel(payload: {
|
||||||
|
channelType: string;
|
||||||
|
name: string;
|
||||||
|
}) {
|
||||||
|
return apiRequestData<{
|
||||||
|
channel: {
|
||||||
|
id: string;
|
||||||
|
channelType: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
testResult: {
|
||||||
|
ok: boolean;
|
||||||
|
code: string;
|
||||||
|
response?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}>(`/api/notifications/channels/test`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,22 @@ import { apiRequestData } from "./http";
|
|||||||
|
|
||||||
export type InvoicePoolStatus = "pending_ocr" | "ocr_failed" | "available" | "bound" | "archived";
|
export type InvoicePoolStatus = "pending_ocr" | "ocr_failed" | "available" | "bound" | "archived";
|
||||||
|
|
||||||
|
export type InvoiceVerificationRecord = {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
status: string;
|
||||||
|
resultCode: string | null;
|
||||||
|
resultMessage: string | null;
|
||||||
|
errorCode: string | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
requestedBy: string | null;
|
||||||
|
requestedAt: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
retryOfId: string | null;
|
||||||
|
attempts: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type InvoicePoolListItem = {
|
export type InvoicePoolListItem = {
|
||||||
id: string;
|
id: string;
|
||||||
status: InvoicePoolStatus;
|
status: InvoicePoolStatus;
|
||||||
@@ -21,6 +37,7 @@ export type InvoicePoolListItem = {
|
|||||||
originalName: string;
|
originalName: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
|
verification: InvoiceVerificationRecord | null;
|
||||||
owner: {
|
owner: {
|
||||||
id: string;
|
id: string;
|
||||||
employeeNo: string;
|
employeeNo: string;
|
||||||
@@ -75,6 +92,7 @@ export type InvoicePoolDetail = {
|
|||||||
overrideRefFormNumber: string | null;
|
overrideRefFormNumber: string | null;
|
||||||
overrideReason: string | null;
|
overrideReason: string | null;
|
||||||
overriddenBy: string | null;
|
overriddenBy: string | null;
|
||||||
|
verification: InvoiceVerificationRecord | null;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
};
|
};
|
||||||
@@ -193,3 +211,29 @@ export async function overrideInvoiceDuplicate(invoiceId: string, payload: { ref
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function triggerInvoiceVerification(invoiceId: string) {
|
||||||
|
return apiRequestData<{ invoiceId: string; verificationId: string; provider: string; triggerMode: string }>(
|
||||||
|
`/api/invoice-pool/${invoiceId}/verify`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInvoiceVerifications(invoiceId: string, limit = 20) {
|
||||||
|
return apiRequestData<{ invoiceId: string; items: InvoiceVerificationRecord[] }>(
|
||||||
|
`/api/invoice-pool/${invoiceId}/verification?limit=${limit}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryInvoiceVerification(invoiceId: string) {
|
||||||
|
return apiRequestData<{ invoiceId: string; verificationId: string; status: string }>(
|
||||||
|
`/api/invoice-pool/${invoiceId}/verification/retry`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,22 @@ export type ReimbursementListResponse = {
|
|||||||
items: ReimbursementListItem[];
|
items: ReimbursementListItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReimbursementBudgetHint = {
|
||||||
|
budgetId: string;
|
||||||
|
budgetCode: string;
|
||||||
|
budgetName: string;
|
||||||
|
controlMode: "warn" | "block";
|
||||||
|
warningThresholdPercent: number;
|
||||||
|
allocatedAmount: string;
|
||||||
|
usedAmount: string;
|
||||||
|
remainingAmount: string;
|
||||||
|
currentAmount: string;
|
||||||
|
nextUsedAmount: string;
|
||||||
|
nextUsagePercent: number;
|
||||||
|
exceedsThreshold: boolean;
|
||||||
|
exceedsBudget: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type ReimbursementDetail = {
|
export type ReimbursementDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
formNumber: string | null;
|
formNumber: string | null;
|
||||||
@@ -86,6 +102,7 @@ export type ReimbursementDetail = {
|
|||||||
invoiceNumber: string | null;
|
invoiceNumber: string | null;
|
||||||
totalAmount: string | null;
|
totalAmount: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
budgetHint: ReimbursementBudgetHint | null;
|
||||||
pendingWorkflow: {
|
pendingWorkflow: {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
stepId: string;
|
stepId: string;
|
||||||
@@ -135,6 +152,22 @@ export type ReimbursementItemDeleteResponse = {
|
|||||||
totalAmount: string;
|
totalAmount: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReimbursementSubmitResponse = {
|
||||||
|
reimbursementId: string;
|
||||||
|
formNumber: string;
|
||||||
|
workflowInstanceId: string;
|
||||||
|
currentTaskId: string;
|
||||||
|
status: "reviewing";
|
||||||
|
budget: {
|
||||||
|
budgetId: string;
|
||||||
|
budgetCode: string;
|
||||||
|
controlMode: "warn" | "block";
|
||||||
|
usagePercent: number;
|
||||||
|
exceededThreshold: boolean;
|
||||||
|
exceededBudget: boolean;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
function toQueryString(query: Record<string, string | number | undefined>) {
|
function toQueryString(query: Record<string, string | number | undefined>) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
@@ -171,7 +204,7 @@ export async function createReimbursement(payload: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function submitReimbursement(reimbursementId: string) {
|
export async function submitReimbursement(reimbursementId: string) {
|
||||||
return apiRequestData<Record<string, unknown>>(`/api/reimbursements/${reimbursementId}/submit`, {
|
return apiRequestData<ReimbursementSubmitResponse>(`/api/reimbursements/${reimbursementId}/submit`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,16 +17,24 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
"expense.approve",
|
"expense.approve",
|
||||||
"expense.transfer",
|
"expense.transfer",
|
||||||
"expense.view_all",
|
"expense.view_all",
|
||||||
|
"report.dashboard.view",
|
||||||
|
"budget.view",
|
||||||
|
"budget.manage",
|
||||||
],
|
],
|
||||||
ceo: [
|
ceo: [
|
||||||
...EMPLOYEE_BASE_PERMISSIONS,
|
...EMPLOYEE_BASE_PERMISSIONS,
|
||||||
"expense.approve",
|
"expense.approve",
|
||||||
"expense.transfer",
|
"expense.transfer",
|
||||||
"expense.view_all",
|
"expense.view_all",
|
||||||
|
"report.dashboard.view",
|
||||||
|
"budget.view",
|
||||||
],
|
],
|
||||||
finance: [
|
finance: [
|
||||||
...EMPLOYEE_BASE_PERMISSIONS,
|
...EMPLOYEE_BASE_PERMISSIONS,
|
||||||
"invoice.manage",
|
"invoice.manage",
|
||||||
|
"invoice.verify",
|
||||||
|
"report.dashboard.view",
|
||||||
|
"budget.view",
|
||||||
"expense.approve",
|
"expense.approve",
|
||||||
"expense.finance.pay",
|
"expense.finance.pay",
|
||||||
"expense.view_all",
|
"expense.view_all",
|
||||||
@@ -34,6 +42,12 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
finance_mgr: [
|
finance_mgr: [
|
||||||
...EMPLOYEE_BASE_PERMISSIONS,
|
...EMPLOYEE_BASE_PERMISSIONS,
|
||||||
"invoice.manage",
|
"invoice.manage",
|
||||||
|
"invoice.verify",
|
||||||
|
"report.dashboard.view",
|
||||||
|
"budget.view",
|
||||||
|
"budget.manage",
|
||||||
|
"integration.manage",
|
||||||
|
"notification.channel.manage",
|
||||||
"expense.approve",
|
"expense.approve",
|
||||||
"expense.finance.pay",
|
"expense.finance.pay",
|
||||||
"expense.view_all",
|
"expense.view_all",
|
||||||
@@ -41,8 +55,18 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
"system.settings",
|
"system.settings",
|
||||||
"system.audit.view",
|
"system.audit.view",
|
||||||
],
|
],
|
||||||
administration: ["expense.proxy_submit", "expense.view_own"],
|
administration: [
|
||||||
hr: ["system.users", "expense.view_all"],
|
"expense.proxy_submit",
|
||||||
|
"expense.create",
|
||||||
|
"expense.view_own",
|
||||||
|
"expense.edit_own",
|
||||||
|
"expense.delete_own",
|
||||||
|
"expense.submit",
|
||||||
|
"expense.withdraw",
|
||||||
|
"invoice.upload",
|
||||||
|
"invoice.view_own",
|
||||||
|
],
|
||||||
|
hr: ["system.users", "system.users_sensitive", "expense.view_all", "report.dashboard.view"],
|
||||||
admin: ["*"],
|
admin: ["*"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
label: "票据中心",
|
label: "票据中心",
|
||||||
anyOf: ["invoice.upload", "invoice.view_own", "invoice.manage"],
|
anyOf: ["invoice.upload", "invoice.view_own", "invoice.manage"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: "/budgets",
|
||||||
|
label: "预算管理",
|
||||||
|
anyOf: ["budget.view", "budget.manage"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: "/audit-logs",
|
to: "/audit-logs",
|
||||||
label: "审计工作台",
|
label: "审计工作台",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+309
-58
@@ -1,77 +1,328 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { useMemo, useState } from "react";
|
||||||
import { EmptyStateCard } from "../components/common/EmptyStateCard";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { exportDashboardReimbursements } from "../api/exports";
|
||||||
|
import {
|
||||||
|
fetchDashboardApprovalEfficiency,
|
||||||
|
fetchDashboardOverview,
|
||||||
|
fetchDashboardTopCategories,
|
||||||
|
fetchDashboardTrends,
|
||||||
|
type DashboardQuery,
|
||||||
|
} from "../api/dashboard";
|
||||||
|
import { fetchDepartments } from "../api/departments";
|
||||||
|
import { isApiError } from "../api/types";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
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 { downloadBlob } from "../lib/download";
|
||||||
|
|
||||||
|
function currentDateString() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthStartString() {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCurrency(value: string | number | null | undefined) {
|
||||||
|
const numeric = Number(value ?? 0);
|
||||||
|
if (!Number.isFinite(numeric)) {
|
||||||
|
return "0.00";
|
||||||
|
}
|
||||||
|
return numeric.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveErrorMessage(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 DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { me, permissions } = useAuth();
|
const { permissions, me } = useAuth();
|
||||||
|
const canViewDashboard = hasPermission(permissions, "report.dashboard.view");
|
||||||
|
|
||||||
|
const [dateFrom, setDateFrom] = useState(monthStartString());
|
||||||
|
const [dateTo, setDateTo] = useState(currentDateString());
|
||||||
|
const [departmentId, setDepartmentId] = useState("");
|
||||||
|
|
||||||
|
const queryParams = useMemo<DashboardQuery>(
|
||||||
|
() => ({
|
||||||
|
dateFrom: dateFrom || undefined,
|
||||||
|
dateTo: dateTo || undefined,
|
||||||
|
departmentId: departmentId || undefined,
|
||||||
|
}),
|
||||||
|
[dateFrom, dateTo, departmentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const departmentsQuery = useQuery({
|
||||||
|
enabled: canViewDashboard,
|
||||||
|
queryKey: ["departments", "dashboard-filter"],
|
||||||
|
queryFn: () => fetchDepartments(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const overviewQuery = useQuery({
|
||||||
|
enabled: canViewDashboard,
|
||||||
|
queryKey: ["dashboard", "overview", queryParams],
|
||||||
|
queryFn: () => fetchDashboardOverview(queryParams),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trendsQuery = useQuery({
|
||||||
|
enabled: canViewDashboard,
|
||||||
|
queryKey: ["dashboard", "trends", queryParams],
|
||||||
|
queryFn: () => fetchDashboardTrends(queryParams),
|
||||||
|
});
|
||||||
|
|
||||||
|
const topCategoriesQuery = useQuery({
|
||||||
|
enabled: canViewDashboard,
|
||||||
|
queryKey: ["dashboard", "top-categories", queryParams],
|
||||||
|
queryFn: () => fetchDashboardTopCategories({ ...queryParams, limit: 8 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const approvalEfficiencyQuery = useQuery({
|
||||||
|
enabled: canViewDashboard,
|
||||||
|
queryKey: ["dashboard", "approval-efficiency", queryParams],
|
||||||
|
queryFn: () => fetchDashboardApprovalEfficiency(queryParams),
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportMutation = useMutation({
|
||||||
|
mutationFn: (format: "csv" | "xlsx") =>
|
||||||
|
exportDashboardReimbursements({
|
||||||
|
format,
|
||||||
|
dateFrom: queryParams.dateFrom,
|
||||||
|
dateTo: queryParams.dateTo,
|
||||||
|
departmentId: queryParams.departmentId,
|
||||||
|
}),
|
||||||
|
onSuccess: (response, format) => {
|
||||||
|
downloadBlob(response.blob, response.filename ?? `dashboard-reimbursements.${format}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!canViewDashboard) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader title="仪表盘" subtitle="你当前没有看板权限,请联系管理员开通。" />
|
||||||
|
<Card className="space-y-2 p-6 text-sm">
|
||||||
|
<p className="text-slate-700">当前用户:{me?.name ?? "-"}</p>
|
||||||
|
<p className="text-slate-500">所需权限:report.dashboard.view</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading =
|
||||||
|
overviewQuery.isLoading ||
|
||||||
|
trendsQuery.isLoading ||
|
||||||
|
topCategoriesQuery.isLoading ||
|
||||||
|
approvalEfficiencyQuery.isLoading;
|
||||||
|
|
||||||
|
const firstError =
|
||||||
|
overviewQuery.error ??
|
||||||
|
trendsQuery.error ??
|
||||||
|
topCategoriesQuery.error ??
|
||||||
|
approvalEfficiencyQuery.error ??
|
||||||
|
departmentsQuery.error ??
|
||||||
|
exportMutation.error;
|
||||||
|
|
||||||
|
const trendPoints = trendsQuery.data?.points ?? [];
|
||||||
|
const maxTrendAmount = Math.max(
|
||||||
|
1,
|
||||||
|
...trendPoints.map((item) => Number(item.amount ?? 0)).filter((value) => Number.isFinite(value)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const topCategories = topCategoriesQuery.data?.items ?? [];
|
||||||
|
const maxCategoryAmount = Math.max(
|
||||||
|
1,
|
||||||
|
...topCategories.map((item) => Number(item.amount ?? 0)).filter((value) => Number.isFinite(value)),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="仪表盘"
|
title="仪表盘"
|
||||||
subtitle="前端壳已完成初始化,后续业务模块将在此基础上扩展。"
|
subtitle="费用概览、趋势、分类与审批效率。支持筛选与导出。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
{firstError ? (
|
||||||
<section className="card-std">
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
<h3 className="card-title">当前用户</h3>
|
{resolveErrorMessage(firstError)}
|
||||||
<dl className="space-y-2 text-sm text-slate-700">
|
</p>
|
||||||
<div className="flex justify-between gap-3">
|
) : null}
|
||||||
<dt className="text-secondary">姓名</dt>
|
|
||||||
<dd className="font-medium text-slate-900">{me?.name ?? "-"}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-3">
|
|
||||||
<dt className="text-secondary">员工号</dt>
|
|
||||||
<dd className="font-medium text-slate-900">{me?.employeeNo ?? "-"}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-3">
|
|
||||||
<dt className="text-secondary">邮箱</dt>
|
|
||||||
<dd className="font-medium text-slate-900">{me?.email ?? "-"}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-3">
|
|
||||||
<dt className="text-secondary">角色</dt>
|
|
||||||
<dd className="font-medium text-slate-900">{me?.roleCodes?.join(" / ") ?? "-"}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card-std">
|
<Card className="space-y-4 p-6">
|
||||||
<h3 className="card-title">权限摘要</h3>
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
{permissions.length ? (
|
<div className="space-y-1">
|
||||||
<div className="flex flex-wrap gap-2">
|
<label className="text-xs text-slate-600" htmlFor="dashboard-date-from">
|
||||||
{permissions.map((permission) => (
|
开始日期
|
||||||
<span
|
</label>
|
||||||
className="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary"
|
<input
|
||||||
key={permission}
|
id="dashboard-date-from"
|
||||||
>
|
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||||
{permission}
|
type="date"
|
||||||
</span>
|
value={dateFrom}
|
||||||
|
onChange={(event) => setDateFrom(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-slate-600" htmlFor="dashboard-date-to">
|
||||||
|
结束日期
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dashboard-date-to"
|
||||||
|
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(event) => setDateTo(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-slate-600" htmlFor="dashboard-department-id">
|
||||||
|
部门
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="dashboard-department-id"
|
||||||
|
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||||
|
value={departmentId}
|
||||||
|
onChange={(event) => setDepartmentId(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">全部部门</option>
|
||||||
|
{(departmentsQuery.data?.items ?? []).map((department) => (
|
||||||
|
<option key={department.id} value={department.id}>
|
||||||
|
{department.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={exportMutation.isPending}
|
||||||
|
onClick={() => exportMutation.mutate("csv")}
|
||||||
|
>
|
||||||
|
导出 CSV
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={exportMutation.isPending}
|
||||||
|
onClick={() => exportMutation.mutate("xlsx")}
|
||||||
|
>
|
||||||
|
导出 Excel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="text-xs text-slate-500">报销单总数</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-slate-900">
|
||||||
|
{loading ? "..." : overviewQuery.data?.totalCount ?? 0}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="text-xs text-slate-500">报销总金额</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-slate-900">
|
||||||
|
{loading ? "..." : toCurrency(overviewQuery.data?.totalAmount)}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="text-xs text-slate-500">已付款金额</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-slate-900">
|
||||||
|
{loading ? "..." : toCurrency(overviewQuery.data?.paidAmount)}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="text-xs text-slate-500">审批中数量</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-slate-900">
|
||||||
|
{loading ? "..." : overviewQuery.data?.reviewingCount ?? 0}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card className="space-y-3 p-6">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">费用趋势</h3>
|
||||||
|
{trendPoints.length ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{trendPoints.map((item) => {
|
||||||
|
const amount = Number(item.amount ?? 0);
|
||||||
|
const width = `${Math.max(5, Math.round((amount / maxTrendAmount) * 100))}%`;
|
||||||
|
return (
|
||||||
|
<div key={item.date} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-600">
|
||||||
|
<span>{item.date}</span>
|
||||||
|
<span>
|
||||||
|
{toCurrency(item.amount)} / {item.count} 笔
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-slate-100">
|
||||||
|
<div className="h-2 rounded-full bg-primary" style={{ width }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="card-desc">暂无权限信息。</p>
|
<p className="text-sm text-slate-500">暂无趋势数据</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="space-y-3 p-6">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">费用分类 TopN</h3>
|
||||||
|
{topCategories.length ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{topCategories.map((item) => {
|
||||||
|
const amount = Number(item.amount ?? 0);
|
||||||
|
const width = `${Math.max(5, Math.round((amount / maxCategoryAmount) * 100))}%`;
|
||||||
|
return (
|
||||||
|
<div key={item.code} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-600">
|
||||||
|
<span>
|
||||||
|
{item.name} ({item.code})
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{toCurrency(item.amount)} / {item.count} 笔
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-slate-100">
|
||||||
|
<div className="h-2 rounded-full bg-emerald-500" style={{ width }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500">暂无分类统计</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 lg:grid-cols-3">
|
<Card className="space-y-2 p-6 text-sm">
|
||||||
<EmptyStateCard
|
<h3 className="text-base font-semibold text-slate-900">审批效率</h3>
|
||||||
title="我的报销"
|
<p className="text-slate-700">
|
||||||
description="下一阶段接入报销列表与详情。"
|
已统计通过单据:
|
||||||
footer={<Link className="text-sm font-medium text-primary" to="/reimbursements">进入页面</Link>}
|
<span className="ml-1 font-medium text-slate-900">
|
||||||
/>
|
{approvalEfficiencyQuery.data?.approvedCount ?? 0}
|
||||||
<EmptyStateCard
|
</span>
|
||||||
title="待我处理"
|
</p>
|
||||||
description="下一阶段接入 workflow 待办列表与动作。"
|
<p className="text-slate-700">
|
||||||
footer={<Link className="text-sm font-medium text-primary" to="/workflow/tasks">进入页面</Link>}
|
平均审批耗时(小时):
|
||||||
/>
|
<span className="ml-1 font-medium text-slate-900">
|
||||||
<EmptyStateCard
|
{approvalEfficiencyQuery.data?.averageHours?.toFixed(2) ?? "0.00"}
|
||||||
title="票据中心"
|
</span>
|
||||||
description="下一阶段接入上传、签名 URL 与绑定流程。"
|
</p>
|
||||||
footer={<Link className="text-sm font-medium text-primary" to="/invoice-pool">进入页面</Link>}
|
</Card>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import { exportInvoicePool } from "../api/exports";
|
|||||||
import {
|
import {
|
||||||
archiveInvoice,
|
archiveInvoice,
|
||||||
bindInvoice,
|
bindInvoice,
|
||||||
|
fetchInvoiceVerifications,
|
||||||
fetchInvoicePoolDetail,
|
fetchInvoicePoolDetail,
|
||||||
fetchInvoicePoolList,
|
fetchInvoicePoolList,
|
||||||
fetchInvoiceSignedUrl,
|
fetchInvoiceSignedUrl,
|
||||||
overrideInvoiceDuplicate,
|
overrideInvoiceDuplicate,
|
||||||
patchInvoice,
|
patchInvoice,
|
||||||
|
retryInvoiceVerification,
|
||||||
retryInvoiceOcr,
|
retryInvoiceOcr,
|
||||||
|
triggerInvoiceVerification,
|
||||||
unbindInvoice,
|
unbindInvoice,
|
||||||
uploadInvoice,
|
uploadInvoice,
|
||||||
type InvoicePoolStatus,
|
type InvoicePoolStatus,
|
||||||
@@ -35,6 +38,7 @@ export function InvoicePoolPage() {
|
|||||||
const canManageInvoice = hasPermission(permissions, "invoice.manage");
|
const canManageInvoice = hasPermission(permissions, "invoice.manage");
|
||||||
const canOverrideDuplicate = hasPermission(permissions, "expense.finance.override_duplicate");
|
const canOverrideDuplicate = hasPermission(permissions, "expense.finance.override_duplicate");
|
||||||
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
||||||
|
const canVerifyInvoice = hasPermission(permissions, "invoice.verify");
|
||||||
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
||||||
const isAdministration = (me?.roleCodes ?? []).includes("administration");
|
const isAdministration = (me?.roleCodes ?? []).includes("administration");
|
||||||
const showProxyOwnerSelector = canProxySubmit && isAdministration;
|
const showProxyOwnerSelector = canProxySubmit && isAdministration;
|
||||||
@@ -90,6 +94,17 @@ export function InvoicePoolPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const verificationQuery = useQuery({
|
||||||
|
enabled: Boolean(detailInvoiceId) && canVerifyInvoice,
|
||||||
|
queryKey: ["invoice-pool", "verification", detailInvoiceId],
|
||||||
|
queryFn: () => fetchInvoiceVerifications(detailInvoiceId, 20),
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const items = query.state.data?.items ?? [];
|
||||||
|
const hasPending = items.some((item) => item.status === "pending");
|
||||||
|
return hasPending ? 3_000 : false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detailQuery.data || editOpen) {
|
if (!detailQuery.data || editOpen) {
|
||||||
return;
|
return;
|
||||||
@@ -251,6 +266,42 @@ export function InvoicePoolPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const triggerVerificationMutation = useMutation({
|
||||||
|
mutationFn: async (invoiceId: string) => triggerInvoiceVerification(invoiceId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }),
|
||||||
|
]);
|
||||||
|
setError("");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
if (isApiError(err)) {
|
||||||
|
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||||
|
} else {
|
||||||
|
setError("触发验真失败,请稍后重试");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const retryVerificationMutation = useMutation({
|
||||||
|
mutationFn: async (invoiceId: string) => retryInvoiceVerification(invoiceId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }),
|
||||||
|
]);
|
||||||
|
setError("");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
if (isApiError(err)) {
|
||||||
|
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||||
|
} else {
|
||||||
|
setError("重试验真失败,请稍后重试");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const overrideDuplicateMutation = useMutation({
|
const overrideDuplicateMutation = useMutation({
|
||||||
mutationFn: async (params: { invoiceId: string; refFormNumber: string; reason: string }) =>
|
mutationFn: async (params: { invoiceId: string; refFormNumber: string; reason: string }) =>
|
||||||
overrideInvoiceDuplicate(params.invoiceId, {
|
overrideInvoiceDuplicate(params.invoiceId, {
|
||||||
@@ -557,8 +608,48 @@ export function InvoicePoolPage() {
|
|||||||
判重例外:{detail.isDuplicateOverride ? "已例外" : "否"}{" "}
|
判重例外:{detail.isDuplicateOverride ? "已例外" : "否"}{" "}
|
||||||
{detail.overrideRefFormNumber ? `(ref=${detail.overrideRefFormNumber})` : ""}
|
{detail.overrideRefFormNumber ? `(ref=${detail.overrideRefFormNumber})` : ""}
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
验真状态:
|
||||||
|
{detail.verification
|
||||||
|
? `${detail.verification.status} (${detail.verification.provider})`
|
||||||
|
: "未触发"}
|
||||||
|
</div>
|
||||||
|
{detail.verification?.resultMessage ? (
|
||||||
|
<div>验真结果:{detail.verification.resultMessage}</div>
|
||||||
|
) : null}
|
||||||
|
{detail.verification?.errorMessage ? (
|
||||||
|
<div className="text-red-600">验真错误:{detail.verification.errorMessage}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 pt-2">
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
{canVerifyInvoice ? (
|
||||||
|
<Button
|
||||||
|
data-testid="invoice-trigger-verification"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={triggerVerificationMutation.isPending || detail.status === "pending_ocr"}
|
||||||
|
onClick={() => triggerVerificationMutation.mutate(detail.id)}
|
||||||
|
>
|
||||||
|
{triggerVerificationMutation.isPending ? "触发中..." : "触发验真"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canVerifyInvoice &&
|
||||||
|
detail.verification &&
|
||||||
|
["failed", "error"].includes(detail.verification.status) ? (
|
||||||
|
<Button
|
||||||
|
data-testid="invoice-retry-verification"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={retryVerificationMutation.isPending}
|
||||||
|
onClick={() => retryVerificationMutation.mutate(detail.id)}
|
||||||
|
>
|
||||||
|
{retryVerificationMutation.isPending ? "重试中..." : "重试验真"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{canEditDetail ? (
|
{canEditDetail ? (
|
||||||
<Button
|
<Button
|
||||||
data-testid="invoice-edit-open"
|
data-testid="invoice-edit-open"
|
||||||
@@ -647,6 +738,31 @@ export function InvoicePoolPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canVerifyInvoice ? (
|
||||||
|
<div className="mt-3 rounded-lg border border-slate-200 p-3" data-testid="invoice-verification-records">
|
||||||
|
<p className="mb-2 text-sm font-medium text-slate-800">验真记录</p>
|
||||||
|
{(verificationQuery.data?.items ?? []).length ? (
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
{(verificationQuery.data?.items ?? []).map((record) => (
|
||||||
|
<div key={record.id} className="rounded-md border border-slate-200 px-2 py-1">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="font-mono">{record.id}</span>
|
||||||
|
<span>{record.status}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-600">
|
||||||
|
provider={record.provider} attempts={record.attempts} requestedAt={record.requestedAt || "-"}
|
||||||
|
</div>
|
||||||
|
{record.resultMessage ? <div className="text-slate-700">{record.resultMessage}</div> : null}
|
||||||
|
{record.errorMessage ? <div className="text-red-600">{record.errorMessage}</div> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-500">暂无验真记录</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{editOpen ? (
|
{editOpen ? (
|
||||||
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 p-4">
|
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 p-4">
|
||||||
<p className="text-sm font-medium text-slate-800">人工修正(仅必要字段)</p>
|
<p className="text-sm font-medium text-slate-800">人工修正(仅必要字段)</p>
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ export function ReimbursementDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const detail = detailQuery.data;
|
const detail = detailQuery.data;
|
||||||
|
const budgetHint = detail.budgetHint;
|
||||||
const canPay = hasPermission(permissions, "expense.finance.pay");
|
const canPay = hasPermission(permissions, "expense.finance.pay");
|
||||||
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
||||||
|
|
||||||
@@ -262,6 +263,29 @@ export function ReimbursementDetailPage() {
|
|||||||
<div>说明:{detail.description || "-"}</div>
|
<div>说明:{detail.description || "-"}</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{budgetHint ? (
|
||||||
|
<Card className="space-y-2 p-6 text-sm" data-testid="reimbursement-budget-hint">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">预算提示</h3>
|
||||||
|
<div>预算池:{budgetHint.budgetCode} / {budgetHint.budgetName}</div>
|
||||||
|
<div>
|
||||||
|
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount}
|
||||||
|
({budgetHint.nextUsagePercent.toFixed(2)}%)
|
||||||
|
</div>
|
||||||
|
<div>控制模式:{budgetHint.controlMode}</div>
|
||||||
|
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (
|
||||||
|
<p className="rounded-md border border-red-200 bg-red-50 px-2 py-1 text-red-600">
|
||||||
|
当前金额将超出预算上限,提交时会被阻断(block 模式)。
|
||||||
|
</p>
|
||||||
|
) : budgetHint.exceedsThreshold ? (
|
||||||
|
<p className="rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
|
||||||
|
当前金额已达到预算预警阈值,提交后会记录预算预警审计。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-slate-500">当前预算充足,可继续提交。</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Card className="space-y-4 p-6" data-testid="reimbursement-items">
|
<Card className="space-y-4 p-6" data-testid="reimbursement-items">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AuthLayout } from "../layouts/AuthLayout";
|
|||||||
import { AuditLogsPage } from "../pages/AuditLogsPage";
|
import { AuditLogsPage } from "../pages/AuditLogsPage";
|
||||||
import { ApprovalRulesPage } from "../pages/ApprovalRulesPage";
|
import { ApprovalRulesPage } from "../pages/ApprovalRulesPage";
|
||||||
import { ChangePasswordPage } from "../pages/ChangePasswordPage";
|
import { ChangePasswordPage } from "../pages/ChangePasswordPage";
|
||||||
|
import { BudgetsPage } from "../pages/BudgetsPage";
|
||||||
import { DashboardPage } from "../pages/DashboardPage";
|
import { DashboardPage } from "../pages/DashboardPage";
|
||||||
import { DepartmentsPage } from "../pages/DepartmentsPage";
|
import { DepartmentsPage } from "../pages/DepartmentsPage";
|
||||||
import { DelegationsPage } from "../pages/DelegationsPage";
|
import { DelegationsPage } from "../pages/DelegationsPage";
|
||||||
@@ -56,6 +57,15 @@ export function AppRouter() {
|
|||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<DashboardPage />} />
|
<Route path="dashboard" element={<DashboardPage />} />
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="budgets"
|
||||||
|
element={
|
||||||
|
<RequirePermission anyOf={["budget.view", "budget.manage"]}>
|
||||||
|
<BudgetsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="reimbursements"
|
path="reimbursements"
|
||||||
element={
|
element={
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户