From 4e5a7793fe8ea9112c32ba96d6c2a38827ba4ec2 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 10 Feb 2026 11:28:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90P6-P8=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E8=83=BD=E5=8A=9B=E9=97=AD=E7=8E=AF=EF=BC=88V3,D2,D3,?= =?UTF-8?q?B3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/budgets.ts | 83 ++++ client/src/api/dashboard.ts | 60 +++ client/src/api/errorMessages.ts | 29 ++ client/src/api/exports.ts | 16 +- client/src/api/integrations.ts | 62 +++ client/src/api/invoicePool.ts | 44 +++ client/src/api/reimbursements.ts | 35 +- client/src/auth/permissions.ts | 28 +- client/src/components/navigation/NavLinks.tsx | 5 + client/src/pages/BudgetsPage.tsx | 334 ++++++++++++++++ client/src/pages/DashboardPage.tsx | 367 +++++++++++++++--- client/src/pages/InvoicePoolPage.tsx | 116 ++++++ client/src/pages/ReimbursementDetailPage.tsx | 24 ++ client/src/router/index.tsx | 10 + 14 files changed, 1151 insertions(+), 62 deletions(-) create mode 100644 client/src/api/budgets.ts create mode 100644 client/src/api/dashboard.ts create mode 100644 client/src/api/integrations.ts create mode 100644 client/src/pages/BudgetsPage.tsx diff --git a/client/src/api/budgets.ts b/client/src/api/budgets.ts new file mode 100644 index 00000000..1b866110 --- /dev/null +++ b/client/src/api/budgets.ts @@ -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(`/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(`/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(`/api/budgets/${budgetId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function archiveBudget(budgetId: string) { + return apiRequestData(`/api/budgets/${budgetId}/archive`, { + method: "POST", + body: JSON.stringify({}), + }); +} diff --git a/client/src/api/dashboard.ts b/client/src/api/dashboard.ts new file mode 100644 index 00000000..519e108a --- /dev/null +++ b/client/src/api/dashboard.ts @@ -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(`/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(`/api/dashboard/approval-efficiency${toQueryString(query)}`); +} diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index ee8c904d..5234c1bf 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -114,6 +114,35 @@ export function mapApiErrorMessage( if (details?.ocr === "disabled") { 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 || "请求失败,请稍后重试。"; diff --git a/client/src/api/exports.ts b/client/src/api/exports.ts index f52f42bd..1ec5beaf 100644 --- a/client/src/api/exports.ts +++ b/client/src/api/exports.ts @@ -41,5 +41,19 @@ export async function exportAuditLogs(params: { 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 }; diff --git a/client/src/api/integrations.ts b/client/src/api/integrations.ts new file mode 100644 index 00000000..c36c4db1 --- /dev/null +++ b/client/src/api/integrations.ts @@ -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; + }; + }>(`/api/notifications/channels/test`, { + method: "POST", + body: JSON.stringify(payload), + }); +} diff --git a/client/src/api/invoicePool.ts b/client/src/api/invoicePool.ts index ff72d431..8d48440a 100644 --- a/client/src/api/invoicePool.ts +++ b/client/src/api/invoicePool.ts @@ -2,6 +2,22 @@ import { apiRequestData } from "./http"; 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 = { id: string; status: InvoicePoolStatus; @@ -21,6 +37,7 @@ export type InvoicePoolListItem = { originalName: string; mimeType: string; fileSize: number; + verification: InvoiceVerificationRecord | null; owner: { id: string; employeeNo: string; @@ -75,6 +92,7 @@ export type InvoicePoolDetail = { overrideRefFormNumber: string | null; overrideReason: string | null; overriddenBy: string | null; + verification: InvoiceVerificationRecord | null; createdAt: string | null; updatedAt: string | null; }; @@ -193,3 +211,29 @@ export async function overrideInvoiceDuplicate(invoiceId: string, payload: { ref 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({}), + }, + ); +} diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index 98fe6709..211a937a 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -59,6 +59,22 @@ export type ReimbursementListResponse = { 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 = { id: string; formNumber: string | null; @@ -86,6 +102,7 @@ export type ReimbursementDetail = { invoiceNumber: string | null; totalAmount: string | null; }>; + budgetHint: ReimbursementBudgetHint | null; pendingWorkflow: { instanceId: string; stepId: string; @@ -135,6 +152,22 @@ export type ReimbursementItemDeleteResponse = { 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) { const params = new URLSearchParams(); @@ -171,7 +204,7 @@ export async function createReimbursement(payload: { } export async function submitReimbursement(reimbursementId: string) { - return apiRequestData>(`/api/reimbursements/${reimbursementId}/submit`, { + return apiRequestData(`/api/reimbursements/${reimbursementId}/submit`, { method: "POST", body: JSON.stringify({}), }); diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index ee128f14..39f08311 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -17,16 +17,24 @@ const ROLE_PERMISSIONS: Record = { "expense.approve", "expense.transfer", "expense.view_all", + "report.dashboard.view", + "budget.view", + "budget.manage", ], ceo: [ ...EMPLOYEE_BASE_PERMISSIONS, "expense.approve", "expense.transfer", "expense.view_all", + "report.dashboard.view", + "budget.view", ], finance: [ ...EMPLOYEE_BASE_PERMISSIONS, "invoice.manage", + "invoice.verify", + "report.dashboard.view", + "budget.view", "expense.approve", "expense.finance.pay", "expense.view_all", @@ -34,6 +42,12 @@ const ROLE_PERMISSIONS: Record = { finance_mgr: [ ...EMPLOYEE_BASE_PERMISSIONS, "invoice.manage", + "invoice.verify", + "report.dashboard.view", + "budget.view", + "budget.manage", + "integration.manage", + "notification.channel.manage", "expense.approve", "expense.finance.pay", "expense.view_all", @@ -41,8 +55,18 @@ const ROLE_PERMISSIONS: Record = { "system.settings", "system.audit.view", ], - administration: ["expense.proxy_submit", "expense.view_own"], - hr: ["system.users", "expense.view_all"], + administration: [ + "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: ["*"], }; diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index c01f0d90..3a9ae95a 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -26,6 +26,11 @@ export const NAV_ITEMS: NavItem[] = [ label: "票据中心", anyOf: ["invoice.upload", "invoice.view_own", "invoice.manage"], }, + { + to: "/budgets", + label: "预算管理", + anyOf: ["budget.view", "budget.manage"], + }, { to: "/audit-logs", label: "审计工作台", diff --git a/client/src/pages/BudgetsPage.tsx b/client/src/pages/BudgetsPage.tsx new file mode 100644 index 00000000..9adfcd53 --- /dev/null +++ b/client/src/pages/BudgetsPage.tsx @@ -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( + () => ({ + 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 ( +
+ + 所需权限:budget.view 或 budget.manage +
+ ); + } + + const totalPages = Math.max(1, Math.ceil((budgetsQuery.data?.total ?? 0) / PAGE_SIZE)); + + return ( +
+ + + {error ? ( +

+ {error} +

+ ) : null} + + +

筛选条件

+
+ + + + + +
+
+ + {canManageBudget ? ( + +

新建预算池

+
+ setCode(event.target.value)} + /> + setName(event.target.value)} + /> + + setPeriodStart(event.target.value)} + /> + setPeriodEnd(event.target.value)} + /> + setAllocatedAmount(event.target.value)} + /> + setWarningThresholdPercent(event.target.value)} + /> +
+ +
+ ) : null} + + +

预算池列表

+ +
+ + + + 编码 + 名称 + 状态 + 期间 + 额度 + 预警阈值 + 操作 + + + + {(budgetsQuery.data?.items ?? []).length ? ( + (budgetsQuery.data?.items ?? []).map((item) => ( + + {item.code} + {item.name} + {item.status} + + {(item.periodStart ?? "-")} ~ {(item.periodEnd ?? "-")} + + + {item.usedAmount} / {item.allocatedAmount} +
剩余 {item.remainingAmount}
+
+ {item.warningThresholdPercent}% + + {canManageBudget && item.status !== "archived" ? ( + + ) : ( + - + )} + +
+ )) + ) : ( + + + 暂无预算池 + + + )} +
+
+
+ +
+ + 第 {page} / {totalPages} 页 + +
+ + +
+
+
+
+ ); +} diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index 3ddfde9b..b2d63592 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -1,77 +1,328 @@ -import { Link } from "react-router-dom"; -import { EmptyStateCard } from "../components/common/EmptyStateCard"; -import { PageHeader } from "../components/common/PageHeader"; +import { useMemo, useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +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 { 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() { - 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( + () => ({ + 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 ( +
+ + +

当前用户:{me?.name ?? "-"}

+

所需权限:report.dashboard.view

+
+
+ ); + } + + 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 ( -
+
-
-
-

当前用户

-
-
-
姓名
-
{me?.name ?? "-"}
-
-
-
员工号
-
{me?.employeeNo ?? "-"}
-
-
-
邮箱
-
{me?.email ?? "-"}
-
-
-
角色
-
{me?.roleCodes?.join(" / ") ?? "-"}
-
-
-
+ {firstError ? ( +

+ {resolveErrorMessage(firstError)} +

+ ) : null} -
-

权限摘要

- {permissions.length ? ( -
- {permissions.map((permission) => ( - - {permission} - + +
+
+ + setDateFrom(event.target.value)} + /> +
+
+ + setDateTo(event.target.value)} + /> +
+
+ + +
+
+ + +
+
+
+ +
+ +

报销单总数

+

+ {loading ? "..." : overviewQuery.data?.totalCount ?? 0} +

+
+ +

报销总金额

+

+ {loading ? "..." : toCurrency(overviewQuery.data?.totalAmount)} +

+
+ +

已付款金额

+

+ {loading ? "..." : toCurrency(overviewQuery.data?.paidAmount)} +

+
+ +

审批中数量

+

+ {loading ? "..." : overviewQuery.data?.reviewingCount ?? 0} +

+
+
+ +
+ +

费用趋势

+ {trendPoints.length ? ( +
+ {trendPoints.map((item) => { + const amount = Number(item.amount ?? 0); + const width = `${Math.max(5, Math.round((amount / maxTrendAmount) * 100))}%`; + return ( +
+
+ {item.date} + + {toCurrency(item.amount)} / {item.count} 笔 + +
+
+
+
+
+ ); + })}
) : ( -

暂无权限信息。

+

暂无趋势数据

)} -
+ + + +

费用分类 TopN

+ {topCategories.length ? ( +
+ {topCategories.map((item) => { + const amount = Number(item.amount ?? 0); + const width = `${Math.max(5, Math.round((amount / maxCategoryAmount) * 100))}%`; + return ( +
+
+ + {item.name} ({item.code}) + + + {toCurrency(item.amount)} / {item.count} 笔 + +
+
+
+
+
+ ); + })} +
+ ) : ( +

暂无分类统计

+ )} +
-
- 进入页面} - /> - 进入页面} - /> - 进入页面} - /> -
+ +

审批效率

+

+ 已统计通过单据: + + {approvalEfficiencyQuery.data?.approvedCount ?? 0} + +

+

+ 平均审批耗时(小时): + + {approvalEfficiencyQuery.data?.averageHours?.toFixed(2) ?? "0.00"} + +

+
); } diff --git a/client/src/pages/InvoicePoolPage.tsx b/client/src/pages/InvoicePoolPage.tsx index bf9b706b..57e4da72 100644 --- a/client/src/pages/InvoicePoolPage.tsx +++ b/client/src/pages/InvoicePoolPage.tsx @@ -7,12 +7,15 @@ import { exportInvoicePool } from "../api/exports"; import { archiveInvoice, bindInvoice, + fetchInvoiceVerifications, fetchInvoicePoolDetail, fetchInvoicePoolList, fetchInvoiceSignedUrl, overrideInvoiceDuplicate, patchInvoice, + retryInvoiceVerification, retryInvoiceOcr, + triggerInvoiceVerification, unbindInvoice, uploadInvoice, type InvoicePoolStatus, @@ -35,6 +38,7 @@ export function InvoicePoolPage() { const canManageInvoice = hasPermission(permissions, "invoice.manage"); const canOverrideDuplicate = hasPermission(permissions, "expense.finance.override_duplicate"); const canUploadInvoice = hasPermission(permissions, "invoice.upload"); + const canVerifyInvoice = hasPermission(permissions, "invoice.verify"); const canProxySubmit = hasPermission(permissions, "expense.proxy_submit"); const isAdministration = (me?.roleCodes ?? []).includes("administration"); 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(() => { if (!detailQuery.data || editOpen) { 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({ mutationFn: async (params: { invoiceId: string; refFormNumber: string; reason: string }) => overrideInvoiceDuplicate(params.invoiceId, { @@ -557,8 +608,48 @@ export function InvoicePoolPage() { 判重例外:{detail.isDuplicateOverride ? "已例外" : "否"}{" "} {detail.overrideRefFormNumber ? `(ref=${detail.overrideRefFormNumber})` : ""}
+
+ 验真状态: + {detail.verification + ? `${detail.verification.status} (${detail.verification.provider})` + : "未触发"} +
+ {detail.verification?.resultMessage ? ( +
验真结果:{detail.verification.resultMessage}
+ ) : null} + {detail.verification?.errorMessage ? ( +
验真错误:{detail.verification.errorMessage}
+ ) : null}
+ {canVerifyInvoice ? ( + + ) : null} + + {canVerifyInvoice && + detail.verification && + ["failed", "error"].includes(detail.verification.status) ? ( + + ) : null} + {canEditDetail ? (