From 7a6f5573a6b99c34d884d1c607a53e672b821bc7 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 8 Feb 2026 23:08:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E5=AE=8C=E6=88=90=20P3=20?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E9=A1=B5=E8=81=94=E5=8A=A8=E9=97=AD=E7=8E=AF?= =?UTF-8?q?=EF=BC=88D6/D8/D10/D11=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 待办:列表/详情 + approve/reject/return/transfer(D6) - 报销:列表/详情 + create/submit/withdraw/pay(D8) - 票据:列表/上传/OCR轮询/绑定/预览(D10) - 统一:Api types + 错误映射 + react-query缓存失效(D11) --- client/README.md | 15 +- client/package-lock.json | 27 ++ client/package.json | 1 + client/src/api/errorMessages.ts | 62 ++++ client/src/api/invoicePool.ts | 130 ++++++++ client/src/api/reimbursements.ts | 152 +++++++++ client/src/api/types.ts | 47 +++ client/src/api/workflow.ts | 163 ++++++++++ client/src/main.tsx | 14 +- client/src/pages/InvoicePoolPage.tsx | 318 ++++++++++++++++++- client/src/pages/ReimbursementDetailPage.tsx | 235 ++++++++++++++ client/src/pages/ReimbursementsPage.tsx | 189 ++++++++++- client/src/pages/WorkflowTaskDetailPage.tsx | 262 +++++++++++++++ client/src/pages/WorkflowTasksPage.tsx | 143 ++++++++- client/src/query/queryClient.ts | 15 + client/src/router/index.tsx | 20 ++ 16 files changed, 1762 insertions(+), 31 deletions(-) create mode 100644 client/src/api/errorMessages.ts create mode 100644 client/src/api/invoicePool.ts create mode 100644 client/src/api/reimbursements.ts create mode 100644 client/src/api/workflow.ts create mode 100644 client/src/pages/ReimbursementDetailPage.tsx create mode 100644 client/src/pages/WorkflowTaskDetailPage.tsx create mode 100644 client/src/query/queryClient.ts diff --git a/client/README.md b/client/README.md index 8ba2ccd7..6dccfb81 100644 --- a/client/README.md +++ b/client/README.md @@ -42,12 +42,23 @@ npm run dev - 审计工作台:`system.audit.view`(finance_mgr/admin)。 - 个人设置:登录即可访问。 +## 6. 页面现状(P3) +- 待我处理:列表 + 详情 + `approve/reject/return/transfer` 动作。 +- 我的报销:分页列表 + 详情 + `submit/withdraw/pay` 联调入口。 +- 票据中心:分页列表 + 上传 + OCR 轮询 + 绑定 + 签名预览。 +- 审计工作台:分页筛选已落地。 + +## 7. 状态管理与错误语义 +- 引入 `@tanstack/react-query` 统一查询缓存与失效策略。 +- 错误文案统一映射在 `client/src/api/errorMessages.ts`。 +- API 层保留后端错误码原文,前端仅做可读化。 + ## 6. 风格资源复用策略(锁定) - 视觉 Token 来源:`/home/shunli/Projects/hyb-web`。 - 采用“复制到 client + 单向同步”,不做跨仓软链与自动联动。 - 如需同步风格,请记录来源 commit 并通过评审。 -## 7. 可用脚本 +## 8. 可用脚本 ```bash npm run dev npm run typecheck @@ -55,7 +66,7 @@ npm run build npm run preview ``` -## 8. 仓库级最小门禁 +## 9. 仓库级最小门禁 ```bash cd /home/shunli/Projects/hyb-oa ./scripts/check-minimal.sh diff --git a/client/package-lock.json b/client/package-lock.json index 2a13685f..1b3f7361 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,7 @@ "name": "client", "version": "0.1.0", "dependencies": { + "@tanstack/react-query": "^5.62.0", "clsx": "^2.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -1165,6 +1166,32 @@ "win32" ] }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz", + "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/client/package.json b/client/package.json index 84d558ee..ad2c99b6 100644 --- a/client/package.json +++ b/client/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-query": "^5.62.0", "clsx": "^2.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts new file mode 100644 index 00000000..4abdfd10 --- /dev/null +++ b/client/src/api/errorMessages.ts @@ -0,0 +1,62 @@ +export function mapApiErrorMessage( + code: string, + details?: Record, + fallbackMessage?: string, +) { + if (code === "AUTH_001") { + return "账号或密码错误,请检查后重试。"; + } + + if (code === "AUTH_004") { + return "登录状态失效,请重新登录。"; + } + + if (code === "PERM_001") { + return "当前账号无权限执行该操作。"; + } + + if (code === "PERM_002") { + return "你不是当前任务处理人,无法执行该操作。"; + } + + if (code === "SYS_404") { + return "目标数据不存在或已被删除。"; + } + + if (code === "VALID_002") { + return "输入内容长度不足,请补充后再提交。"; + } + + if (code === "VALID_001") { + if (details?.payment === "already_paid_conflict") { + return "该报销单已支付且付款信息冲突,请刷新后确认。"; + } + + if (details?.status === "invalid_pay_status") { + return "当前状态不允许执行付款。"; + } + + if (details?.transfer === "chain_limit_exceeded") { + return "当前任务转审次数已达上限。"; + } + + if (details?.transfer === "same_assignee_not_allowed") { + return "不能将任务转审给自己。"; + } + + if (details?.toApproverId === "target_not_approver") { + return "目标人员不具备审批权限。"; + } + + if (details?.toApproverId === "invalid_target") { + return "目标审批人不存在或已停用。"; + } + + if (details?.task === "already_processed") { + return "该任务已被处理,请刷新列表。"; + } + } + + return fallbackMessage || "请求失败,请稍后重试。"; +} + diff --git a/client/src/api/invoicePool.ts b/client/src/api/invoicePool.ts new file mode 100644 index 00000000..e1bc4460 --- /dev/null +++ b/client/src/api/invoicePool.ts @@ -0,0 +1,130 @@ +import { apiRequestData } from "./http"; + +export type InvoicePoolStatus = "pending_ocr" | "ocr_failed" | "available" | "bound" | "archived"; + +export type InvoicePoolListItem = { + id: string; + status: InvoicePoolStatus; + ocrType: string; + ocrProvider: string; + ocrAttempts: number; + ocrLastError: string | null; + ocrCompletedAt: string | null; + invoiceCode: string | null; + invoiceNumber: string | null; + invoiceDate: string | null; + amount: string | null; + taxAmount: string | null; + totalAmount: string | null; + linkedReimbursementId: string | null; + linkedItemId: string | null; + originalName: string; + mimeType: string; + fileSize: number; + owner: { + id: string; + employeeNo: string; + name: string; + } | null; + uploadedBy: { + id: string; + employeeNo: string; + name: string; + } | null; + createdAt: string | null; + updatedAt: string | null; +}; + +export type InvoicePoolListResponse = { + page: number; + pageSize: number; + total: number; + ownerScope: "own" | "all"; + items: InvoicePoolListItem[]; +}; + +export type InvoicePoolDetail = { + id: string; + status: InvoicePoolStatus; + ownerEmployeeId: string; + uploadedByEmployeeId: string; + ocrType: string; + ocrProvider: string; + ocrAttempts: number; + ocrLastError: string | null; + ocrCompletedAt: string | null; + invoiceCode: string | null; + invoiceNumber: string | null; + invoiceDate: string | null; + amount: string | null; + taxAmount: string | null; + totalAmount: string | null; + linkedReimbursementId: string | null; + linkedItemId: string | null; + createdAt: string | null; + updatedAt: string | null; +}; + +export type InvoicePoolListQuery = { + page?: number; + pageSize?: number; + status?: InvoicePoolStatus; + ownerScope?: "own" | "all"; +}; + +function toQueryString(query: Record) { + const params = new URLSearchParams(); + + for (const [key, value] of Object.entries(query)) { + if (value == null || value === "") { + continue; + } + params.set(key, String(value)); + } + + const encoded = params.toString(); + return encoded ? `?${encoded}` : ""; +} + +export async function fetchInvoicePoolList(query: InvoicePoolListQuery = {}) { + return apiRequestData( + `/api/invoice-pool${toQueryString(query as Record)}`, + ); +} + +export async function fetchInvoicePoolDetail(invoiceId: string) { + return apiRequestData(`/api/invoice-pool/${invoiceId}`); +} + +export async function uploadInvoice(file: File, ocrType?: string) { + const formData = new FormData(); + formData.append("file", file); + if (ocrType) { + formData.append("ocr_type", ocrType); + } + + return apiRequestData<{ invoicePoolId: string; status: string; fileSha256: string }>( + "/api/invoice-pool/upload", + { + method: "POST", + body: formData, + }, + ); +} + +export async function bindInvoice(invoiceId: string, reimbursementId: string, linkedItemId?: string) { + return apiRequestData>(`/api/invoice-pool/${invoiceId}/bind`, { + method: "POST", + body: JSON.stringify({ reimbursementId, linkedItemId }), + }); +} + +export async function fetchInvoiceSignedUrl(invoiceId: string) { + return apiRequestData<{ signedUrl: string; expiresAt: string }>( + `/api/invoice-pool/${invoiceId}/signed-url`, + { + method: "POST", + body: JSON.stringify({}), + }, + ); +} diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts new file mode 100644 index 00000000..12e72e9f --- /dev/null +++ b/client/src/api/reimbursements.ts @@ -0,0 +1,152 @@ +import { apiRequestData } from "./http"; + +export type ReimbursementStatus = + | "draft" + | "reviewing" + | "approved" + | "paid" + | "rejected" + | "cancelled"; + +export type ReimbursementListItem = { + id: string; + formNumber: string | null; + status: ReimbursementStatus; + reimbursementDate: string | null; + totalAmount: string | null; + description: string | null; + payment: { + paymentVoucherNo: string | null; + paymentDate: string | null; + paymentChannel: string | null; + paidAt: string | null; + paidBy: string | null; + }; + owner: { + id: string; + employeeNo: string; + name: string; + } | null; + submitter: { + id: string; + employeeNo: string; + name: string; + } | null; + createdAt: string | null; + updatedAt: string | null; +}; + +export type ReimbursementListResponse = { + page: number; + pageSize: number; + total: number; + scope: "own" | "all"; + items: ReimbursementListItem[]; +}; + +export type ReimbursementDetail = { + id: string; + formNumber: string | null; + submitterId: string; + ownerId: string; + departmentId: string; + status: ReimbursementStatus; + reimbursementDate: string | null; + totalAmount: string | null; + description: string | null; + owner: { + id: string; + employee_no: string; + name: string; + } | null; + submitter: { + id: string; + employee_no: string; + name: string; + } | null; + invoices: Array<{ + id: string; + status: string; + invoiceCode: string | null; + invoiceNumber: string | null; + totalAmount: string | null; + }>; + pendingWorkflow: { + instanceId: string; + stepId: string; + task: { + id: string; + step_name: string; + assignee_id: string | null; + assignee_role: string | null; + } | null; + } | null; +}; + +export type ReimbursementListQuery = { + page?: number; + pageSize?: number; + status?: ReimbursementStatus; + scope?: "own" | "all"; +}; + +export type ReimbursementPayPayload = { + paymentVoucherNo?: string; + paymentDate?: string; + paymentChannel?: "bank" | "alipay" | "wechat" | "cash" | "other"; +}; + +function toQueryString(query: Record) { + const params = new URLSearchParams(); + + for (const [key, value] of Object.entries(query)) { + if (value == null || value === "") { + continue; + } + params.set(key, String(value)); + } + + const encoded = params.toString(); + return encoded ? `?${encoded}` : ""; +} + +export async function fetchReimbursements(query: ReimbursementListQuery = {}) { + return apiRequestData( + `/api/reimbursements${toQueryString(query as Record)}`, + ); +} + +export async function fetchReimbursementDetail(reimbursementId: string) { + return apiRequestData(`/api/reimbursements/${reimbursementId}`); +} + +export async function createReimbursement(payload: { + reimbursementDate?: string; + description?: string; +} = {}) { + return apiRequestData<{ id: string }>(`/api/reimbursements`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function submitReimbursement(reimbursementId: string) { + return apiRequestData>(`/api/reimbursements/${reimbursementId}/submit`, { + method: "POST", + body: JSON.stringify({}), + }); +} + +export async function withdrawReimbursement(reimbursementId: string, comment?: string) { + return apiRequestData>(`/api/reimbursements/${reimbursementId}/withdraw`, { + method: "POST", + body: JSON.stringify({ comment }), + }); +} + +export async function payReimbursement(reimbursementId: string, payload: ReimbursementPayPayload = {}) { + return apiRequestData>(`/api/reimbursements/${reimbursementId}/pay`, { + method: "POST", + body: JSON.stringify(payload), + }); +} diff --git a/client/src/api/types.ts b/client/src/api/types.ts index 100743fc..cb5a710a 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -65,3 +65,50 @@ export class ApiError extends Error { export function isApiError(value: unknown): value is ApiError { return value instanceof ApiError; } + + +export type WorkflowTaskSummary = { + id: string; + instanceId: string; + stepId: string; + stepName: string; + assigneeId: string | null; + assigneeRole: string | null; + status: string; + outcome: string | null; + createdAt: string; + completedAt: string | null; +}; + +export type WorkflowTaskDetail = { + task: { + id: string; + instanceId: string; + stepId: string; + stepName: string; + assigneeId: string | null; + assigneeRole: string | null; + status: string; + outcome: string | null; + comment: string | null; + createdAt: string | null; + completedAt: string | null; + }; + allowedActions: Array<"approve" | "reject" | "return" | "transfer">; +}; + +export type ReimbursementListItem = { + id: string; + formNumber: string | null; + status: string; + reimbursementDate: string | null; + totalAmount: string | null; +}; + +export type InvoicePoolListItem = { + id: string; + status: string; + invoiceCode: string | null; + invoiceNumber: string | null; + ocrLastError: string | null; +}; diff --git a/client/src/api/workflow.ts b/client/src/api/workflow.ts new file mode 100644 index 00000000..bf7e4fe7 --- /dev/null +++ b/client/src/api/workflow.ts @@ -0,0 +1,163 @@ +import { apiRequestData } from "./http"; + +export type WorkflowTaskSummary = { + id: string; + instanceId: string; + stepId: string; + stepName: string; + assigneeId: string | null; + assigneeRole: string | null; + status: "pending" | "completed" | "rejected" | "cancelled" | "skipped"; + outcome: string | null; + createdAt: string; + completedAt: string | null; + workflowInstance: { + entityType: string; + entityId: string; + currentStatus: string; + } | null; +}; + +export type WorkflowTaskListResponse = { + page: number; + pageSize: number; + total: number; + items: WorkflowTaskSummary[]; +}; + +export type WorkflowHistoryItem = { + id: string; + taskId: string | null; + actorId: string; + actorName: string; + actorEmployeeNo: string; + action: string; + comment: string | null; + payload: Record | null; + createdAt: string | null; +}; + +export type WorkflowTaskDetail = { + task: { + id: string; + instanceId: string; + stepId: string; + stepName: string; + assigneeId: string | null; + assigneeRole: string | null; + status: string; + outcome: string | null; + comment: string | null; + createdAt: string | null; + completedAt: string | null; + }; + workflowInstance: { + id: string; + currentStatus: string; + currentStepId: string; + startedAt: string | null; + completedAt: string | null; + } | null; + reimbursement: { + id: string; + formNumber: string | null; + status: string; + reimbursementDate: string | null; + totalAmount: string | null; + description: string | null; + owner: { + id: string; + employeeNo: string; + name: string; + } | null; + submitter: { + id: string; + employeeNo: string; + name: string; + } | null; + } | null; + allowedActions: Array<"approve" | "reject" | "return" | "transfer">; + history: WorkflowHistoryItem[]; +}; + +export type WorkflowApprover = { + id: string; + employeeNo: string; + name: string; + email: string; + roleCodes: string[]; +}; + +export type WorkflowApproverListResponse = { + page: number; + pageSize: number; + total: number; + items: WorkflowApprover[]; +}; + +export type WorkflowTaskListQuery = { + status?: "pending" | "completed" | "rejected" | "cancelled" | "skipped"; + page?: number; + pageSize?: number; +}; + +export type WorkflowApproverQuery = { + keyword?: string; + page?: number; + pageSize?: number; +}; + +function toQueryString(query: Record) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value == null || value === "") { + continue; + } + params.set(key, String(value)); + } + + const encoded = params.toString(); + return encoded ? `?${encoded}` : ""; +} + +export async function fetchWorkflowTasks(query: WorkflowTaskListQuery) { + return apiRequestData(`/api/workflow/tasks${toQueryString(query)}`); +} + +export async function fetchWorkflowTaskDetail(taskId: string) { + return apiRequestData(`/api/workflow/tasks/${taskId}`); +} + +export async function fetchWorkflowApprovers(query: WorkflowApproverQuery = {}) { + return apiRequestData( + `/api/workflow/approvers${toQueryString(query as Record)}`, + ); +} + +export async function approveWorkflowTask(taskId: string) { + return apiRequestData>(`/api/workflow/tasks/${taskId}/approve`, { + method: "POST", + body: JSON.stringify({}), + }); +} + +export async function rejectWorkflowTask(taskId: string, comment: string) { + return apiRequestData>(`/api/workflow/tasks/${taskId}/reject`, { + method: "POST", + body: JSON.stringify({ comment }), + }); +} + +export async function returnWorkflowTask(taskId: string, comment: string) { + return apiRequestData>(`/api/workflow/tasks/${taskId}/return`, { + method: "POST", + body: JSON.stringify({ comment }), + }); +} + +export async function transferWorkflowTask(taskId: string, toApproverId: string, reason: string) { + return apiRequestData>(`/api/workflow/tasks/${taskId}/transfer`, { + method: "POST", + body: JSON.stringify({ toApproverId, reason }), + }); +} diff --git a/client/src/main.tsx b/client/src/main.tsx index 0b215440..db7ee339 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,16 +1,20 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import { QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter } from "react-router-dom"; import { AuthProvider } from "./auth/AuthContext"; +import { queryClient } from "./query/queryClient"; import { AppRouter } from "./router"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - + + + + + + + , ); diff --git a/client/src/pages/InvoicePoolPage.tsx b/client/src/pages/InvoicePoolPage.tsx index 3d07199c..e42453e7 100644 --- a/client/src/pages/InvoicePoolPage.tsx +++ b/client/src/pages/InvoicePoolPage.tsx @@ -1,17 +1,321 @@ -import { EmptyStateCard } from "../components/common/EmptyStateCard"; +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { isApiError } from "../api/types"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { + bindInvoice, + fetchInvoicePoolDetail, + fetchInvoicePoolList, + fetchInvoiceSignedUrl, + uploadInvoice, + type InvoicePoolStatus, +} from "../api/invoicePool"; +import { fetchReimbursements } from "../api/reimbursements"; +import { useAuth } from "../auth/AuthContext"; +import { hasPermission } from "../auth/permissions"; import { PageHeader } from "../components/common/PageHeader"; +const PAGE_SIZE = 20; + export function InvoicePoolPage() { + const queryClient = useQueryClient(); + const { permissions } = useAuth(); + const canManageInvoice = hasPermission(permissions, "invoice.manage"); + const [status, setStatus] = useState(""); + const [ownerScope, setOwnerScope] = useState<"own" | "all">("own"); + const [page, setPage] = useState(1); + const [selectedFile, setSelectedFile] = useState(null); + const [selectedInvoiceId, setSelectedInvoiceId] = useState(""); + const [selectedReimbursementId, setSelectedReimbursementId] = useState(""); + const [detailInvoiceId, setDetailInvoiceId] = useState(""); + const [error, setError] = useState(""); + + const listQuery = useQuery({ + queryKey: ["invoice-pool", "list", status, ownerScope, page], + queryFn: () => + fetchInvoicePoolList({ + status: status || undefined, + ownerScope, + page, + pageSize: PAGE_SIZE, + }), + }); + + const reimbursementQuery = useQuery({ + queryKey: ["reimbursements", "for-bind"], + queryFn: () => fetchReimbursements({ page: 1, pageSize: 50, scope: "own" }), + }); + + const detailQuery = useQuery({ + enabled: Boolean(detailInvoiceId), + queryKey: ["invoice-pool", "detail", detailInvoiceId], + queryFn: () => fetchInvoicePoolDetail(detailInvoiceId), + refetchInterval: (query) => { + const statusValue = query.state.data?.status; + if (statusValue === "pending_ocr") { + return 3_000; + } + return false; + }, + }); + + const uploadMutation = useMutation({ + mutationFn: async () => { + if (!selectedFile) { + throw new Error("请选择文件"); + } + return uploadInvoice(selectedFile, "vat"); + }, + onSuccess: async (data) => { + await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); + setDetailInvoiceId(data.invoicePoolId); + setSelectedFile(null); + setError(""); + }, + onError: (err) => { + if (isApiError(err)) { + setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); + } else if (err instanceof Error) { + setError(err.message); + } else { + setError("上传失败,请稍后重试"); + } + }, + }); + + const bindMutation = useMutation({ + mutationFn: async () => { + if (!selectedInvoiceId || !selectedReimbursementId) { + throw new Error("请选择票据与报销单"); + } + return bindInvoice(selectedInvoiceId, selectedReimbursementId); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); + setError(""); + }, + onError: (err) => { + if (isApiError(err)) { + setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); + } else if (err instanceof Error) { + setError(err.message); + } else { + setError("绑定失败,请稍后重试"); + } + }, + }); + + const signedUrlMutation = useMutation({ + mutationFn: async (invoiceId: string) => fetchInvoiceSignedUrl(invoiceId), + onSuccess: (data) => { + window.open(data.signedUrl, "_blank", "noopener,noreferrer"); + }, + onError: (err) => { + if (isApiError(err)) { + setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); + } else { + setError("预览链接获取失败"); + } + }, + }); + + useEffect(() => { + if (!canManageInvoice && ownerScope === "all") { + setOwnerScope("own"); + } + }, [canManageInvoice, ownerScope]); + + useEffect(() => { + if (!detailInvoiceId && listQuery.data?.items?.length) { + setDetailInvoiceId(listQuery.data.items[0].id); + } + }, [detailInvoiceId, listQuery.data?.items]); + + const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); + return ( -
+
- + + {error ? ( +

{error}

+ ) : null} + +
+

上传票据(VAT OCR)

+ setSelectedFile(event.target.files?.[0] ?? null)} + type="file" + /> + +
+ +
+

筛选与列表

+
+ + + +
+ +
+ + + + + + + + + + + + {(listQuery.data?.items ?? []).length ? ( + listQuery.data?.items.map((item) => ( + + + + + + + + )) + ) : ( + + + + )} + +
ID状态发票号OCR操作
{item.id}{item.status} + {item.invoiceCode || "-"}-{item.invoiceNumber || "-"} + + attempts={item.ocrAttempts} + {item.ocrLastError ? ` / ${item.ocrLastError}` : ""} + +
+ + +
+
+ 暂无数据 +
+
+ +
+ + 第 {page} / {totalPages} 页 + +
+ + +
+
+
+ +
+

绑定报销

+ + + + + +
+ +
+

详情轮询

+ {detailQuery.data ? ( + <> +
票据ID:{detailQuery.data.id}
+
状态:{detailQuery.data.status}
+
OCR错误:{detailQuery.data.ocrLastError || "-"}
+
+ 发票号:{detailQuery.data.invoiceCode || "-"}-{detailQuery.data.invoiceNumber || "-"} +
+ + ) : ( +

请选择一条票据查看详情

+ )} +
); } diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx new file mode 100644 index 00000000..f8967f57 --- /dev/null +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -0,0 +1,235 @@ +import { useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { isApiError } from "../api/types"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { + fetchReimbursementDetail, + payReimbursement, + submitReimbursement, + withdrawReimbursement, +} from "../api/reimbursements"; +import { useAuth } from "../auth/AuthContext"; +import { hasPermission } from "../auth/permissions"; +import { PageHeader } from "../components/common/PageHeader"; + +export function ReimbursementDetailPage() { + const { reimbursementId = "" } = useParams(); + const { permissions } = useAuth(); + const queryClient = useQueryClient(); + + const [withdrawComment, setWithdrawComment] = useState("申请撤回并补充说明后重提"); + const [paymentVoucherNo, setPaymentVoucherNo] = useState(""); + const [paymentDate, setPaymentDate] = useState("2026-02-08"); + const [paymentChannel, setPaymentChannel] = useState<"bank" | "alipay" | "wechat" | "cash" | "other">( + "bank", + ); + const [actionError, setActionError] = useState(""); + + const detailQuery = useQuery({ + enabled: Boolean(reimbursementId), + queryKey: ["reimbursements", reimbursementId], + queryFn: () => fetchReimbursementDetail(reimbursementId), + }); + + const mutation = useMutation({ + mutationFn: async (action: "submit" | "withdraw" | "pay") => { + if (!reimbursementId) { + throw new Error("missing reimbursementId"); + } + + if (action === "submit") { + return submitReimbursement(reimbursementId); + } + + if (action === "withdraw") { + return withdrawReimbursement(reimbursementId, withdrawComment.trim()); + } + + return payReimbursement(reimbursementId, { + paymentVoucherNo: paymentVoucherNo.trim() || undefined, + paymentDate: paymentDate || undefined, + paymentChannel, + }); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), + queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), + ]); + setActionError(""); + }, + onError: (error) => { + if (isApiError(error)) { + setActionError( + `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`, + ); + return; + } + + if (error instanceof Error) { + setActionError(error.message); + return; + } + + setActionError("操作失败,请稍后重试"); + }, + }); + + if (!reimbursementId) { + return ( +
+

缺少 reimbursementId 参数。

+
+ ); + } + + if (detailQuery.isLoading) { + return ( +
+

加载报销详情中...

+
+ ); + } + + if (detailQuery.isError || !detailQuery.data) { + const message = isApiError(detailQuery.error) + ? `${detailQuery.error.code}: ${mapApiErrorMessage( + detailQuery.error.code, + detailQuery.error.details, + detailQuery.error.message, + )}` + : "加载报销详情失败"; + + return ( +
+

{message}

+ + 返回报销列表 + +
+ ); + } + + const detail = detailQuery.data; + const canPay = hasPermission(permissions, "expense.finance.pay"); + + return ( +
+ + 返回列表 + + } + /> + +
+
报销ID:{detail.id}
+
报销日期:{detail.reimbursementDate ?? "-"}
+
金额:{detail.totalAmount ?? "-"}
+
提交人:{detail.submitter?.name ?? "-"}
+
报销人:{detail.owner?.name ?? "-"}
+
说明:{detail.description || "-"}
+
+ +
+

可执行动作

+ + {actionError ? ( +

+ {actionError} +

+ ) : null} + +
+ +
+ +
+

撤回报销

+