feat(client): 完成 P3 业务页联动闭环(D6/D8/D10/D11)
- 待办:列表/详情 + approve/reject/return/transfer(D6) - 报销:列表/详情 + create/submit/withdraw/pay(D8) - 票据:列表/上传/OCR轮询/绑定/预览(D10) - 统一:Api types + 错误映射 + react-query缓存失效(D11)
这个提交包含在:
+13
-2
@@ -42,12 +42,23 @@ npm run dev
|
|||||||
- 审计工作台:`system.audit.view`(finance_mgr/admin)。
|
- 审计工作台:`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. 风格资源复用策略(锁定)
|
## 6. 风格资源复用策略(锁定)
|
||||||
- 视觉 Token 来源:`/home/shunli/Projects/hyb-web`。
|
- 视觉 Token 来源:`/home/shunli/Projects/hyb-web`。
|
||||||
- 采用“复制到 client + 单向同步”,不做跨仓软链与自动联动。
|
- 采用“复制到 client + 单向同步”,不做跨仓软链与自动联动。
|
||||||
- 如需同步风格,请记录来源 commit 并通过评审。
|
- 如需同步风格,请记录来源 commit 并通过评审。
|
||||||
|
|
||||||
## 7. 可用脚本
|
## 8. 可用脚本
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
@@ -55,7 +66,7 @@ npm run build
|
|||||||
npm run preview
|
npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
## 8. 仓库级最小门禁
|
## 9. 仓库级最小门禁
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa
|
cd /home/shunli/Projects/hyb-oa
|
||||||
./scripts/check-minimal.sh
|
./scripts/check-minimal.sh
|
||||||
|
|||||||
+27
@@ -8,6 +8,7 @@
|
|||||||
"name": "client",
|
"name": "client",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -1165,6 +1166,32 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export function mapApiErrorMessage(
|
||||||
|
code: string,
|
||||||
|
details?: Record<string, unknown>,
|
||||||
|
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 || "请求失败,请稍后重试。";
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<string, string | number | undefined>) {
|
||||||
|
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<InvoicePoolListResponse>(
|
||||||
|
`/api/invoice-pool${toQueryString(query as Record<string, string | number | undefined>)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInvoicePoolDetail(invoiceId: string) {
|
||||||
|
return apiRequestData<InvoicePoolDetail>(`/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<Record<string, unknown>>(`/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({}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string | number | undefined>) {
|
||||||
|
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<ReimbursementListResponse>(
|
||||||
|
`/api/reimbursements${toQueryString(query as Record<string, string | number | undefined>)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReimbursementDetail(reimbursementId: string) {
|
||||||
|
return apiRequestData<ReimbursementDetail>(`/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<Record<string, unknown>>(`/api/reimbursements/${reimbursementId}/submit`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withdrawReimbursement(reimbursementId: string, comment?: string) {
|
||||||
|
return apiRequestData<Record<string, unknown>>(`/api/reimbursements/${reimbursementId}/withdraw`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ comment }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function payReimbursement(reimbursementId: string, payload: ReimbursementPayPayload = {}) {
|
||||||
|
return apiRequestData<Record<string, unknown>>(`/api/reimbursements/${reimbursementId}/pay`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -65,3 +65,50 @@ export class ApiError extends Error {
|
|||||||
export function isApiError(value: unknown): value is ApiError {
|
export function isApiError(value: unknown): value is ApiError {
|
||||||
return value instanceof 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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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<string, unknown> | 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<string, string | number | undefined>) {
|
||||||
|
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<WorkflowTaskListResponse>(`/api/workflow/tasks${toQueryString(query)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWorkflowTaskDetail(taskId: string) {
|
||||||
|
return apiRequestData<WorkflowTaskDetail>(`/api/workflow/tasks/${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWorkflowApprovers(query: WorkflowApproverQuery = {}) {
|
||||||
|
return apiRequestData<WorkflowApproverListResponse>(
|
||||||
|
`/api/workflow/approvers${toQueryString(query as Record<string, string | number | undefined>)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveWorkflowTask(taskId: string) {
|
||||||
|
return apiRequestData<Record<string, unknown>>(`/api/workflow/tasks/${taskId}/approve`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectWorkflowTask(taskId: string, comment: string) {
|
||||||
|
return apiRequestData<Record<string, unknown>>(`/api/workflow/tasks/${taskId}/reject`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ comment }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function returnWorkflowTask(taskId: string, comment: string) {
|
||||||
|
return apiRequestData<Record<string, unknown>>(`/api/workflow/tasks/${taskId}/return`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ comment }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transferWorkflowTask(taskId: string, toApproverId: string, reason: string) {
|
||||||
|
return apiRequestData<Record<string, unknown>>(`/api/workflow/tasks/${taskId}/transfer`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ toApproverId, reason }),
|
||||||
|
});
|
||||||
|
}
|
||||||
+9
-5
@@ -1,16 +1,20 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { AuthProvider } from "./auth/AuthContext";
|
import { AuthProvider } from "./auth/AuthContext";
|
||||||
|
import { queryClient } from "./query/queryClient";
|
||||||
import { AppRouter } from "./router";
|
import { AppRouter } from "./router";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<BrowserRouter>
|
||||||
<AppRouter />
|
<AuthProvider>
|
||||||
</AuthProvider>
|
<AppRouter />
|
||||||
</BrowserRouter>
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
export function InvoicePoolPage() {
|
export function InvoicePoolPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { permissions } = useAuth();
|
||||||
|
const canManageInvoice = hasPermission(permissions, "invoice.manage");
|
||||||
|
const [status, setStatus] = useState<InvoicePoolStatus | "">("");
|
||||||
|
const [ownerScope, setOwnerScope] = useState<"own" | "all">("own");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(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 (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="票据中心"
|
title="票据中心"
|
||||||
subtitle="此页面已接入权限守卫,后续将接入上传、绑定、签名 URL 等功能。"
|
subtitle="支持上传、OCR 状态轮询、绑定报销与签名预览。"
|
||||||
/>
|
|
||||||
<EmptyStateCard
|
|
||||||
title="功能建设中"
|
|
||||||
description="后续迭代将实现发票池列表、上传、绑定、归档与签名预览。"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="card-std space-y-3">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">上传票据(VAT OCR)</h3>
|
||||||
|
<input
|
||||||
|
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||||
|
onChange={(event) => setSelectedFile(event.target.files?.[0] ?? null)}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
<button className="btn-primary" onClick={() => uploadMutation.mutate()} type="button">
|
||||||
|
{uploadMutation.isPending ? "上传中..." : "上传并触发OCR"}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card-std space-y-3">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">筛选与列表</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<select
|
||||||
|
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||||
|
onChange={(event) => {
|
||||||
|
setStatus(event.target.value as InvoicePoolStatus | "");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
value={status}
|
||||||
|
>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="pending_ocr">pending_ocr</option>
|
||||||
|
<option value="available">available</option>
|
||||||
|
<option value="ocr_failed">ocr_failed</option>
|
||||||
|
<option value="bound">bound</option>
|
||||||
|
<option value="archived">archived</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||||
|
onChange={(event) => {
|
||||||
|
setOwnerScope(event.target.value as "own" | "all");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
value={ownerScope}
|
||||||
|
>
|
||||||
|
<option value="own">own</option>
|
||||||
|
<option value="all">all</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-slate-200">
|
||||||
|
<table className="min-w-full text-left text-sm">
|
||||||
|
<thead className="bg-slate-50 text-slate-600">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2">ID</th>
|
||||||
|
<th className="px-3 py-2">状态</th>
|
||||||
|
<th className="px-3 py-2">发票号</th>
|
||||||
|
<th className="px-3 py-2">OCR</th>
|
||||||
|
<th className="px-3 py-2">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(listQuery.data?.items ?? []).length ? (
|
||||||
|
listQuery.data?.items.map((item) => (
|
||||||
|
<tr className="border-t border-slate-100" key={item.id}>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">{item.id}</td>
|
||||||
|
<td className="px-3 py-2">{item.status}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
attempts={item.ocrAttempts}
|
||||||
|
{item.ocrLastError ? ` / ${item.ocrLastError}` : ""}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={() => setDetailInvoiceId(item.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={() => signedUrlMutation.mutate(item.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
预览
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-3 text-slate-500" colSpan={5}>
|
||||||
|
暂无数据
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>
|
||||||
|
第 {page} / {totalPages} 页
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card-std space-y-3">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">绑定报销</h3>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||||
|
onChange={(event) => setSelectedInvoiceId(event.target.value)}
|
||||||
|
value={selectedInvoiceId}
|
||||||
|
>
|
||||||
|
<option value="">选择票据</option>
|
||||||
|
{(listQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.id} ({item.status})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||||
|
onChange={(event) => setSelectedReimbursementId(event.target.value)}
|
||||||
|
value={selectedReimbursementId}
|
||||||
|
>
|
||||||
|
<option value="">选择报销单</option>
|
||||||
|
{(reimbursementQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.formNumber ?? item.id} ({item.status})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button className="btn-secondary" onClick={() => bindMutation.mutate()} type="button">
|
||||||
|
{bindMutation.isPending ? "绑定中..." : "绑定票据"}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card-std space-y-2 text-sm">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">详情轮询</h3>
|
||||||
|
{detailQuery.data ? (
|
||||||
|
<>
|
||||||
|
<div>票据ID:{detailQuery.data.id}</div>
|
||||||
|
<div>状态:{detailQuery.data.status}</div>
|
||||||
|
<div>OCR错误:{detailQuery.data.ocrLastError || "-"}</div>
|
||||||
|
<div>
|
||||||
|
发票号:{detailQuery.data.invoiceCode || "-"}-{detailQuery.data.invoiceNumber || "-"}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-slate-500">请选择一条票据查看详情</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<section className="card-std">
|
||||||
|
<p className="text-sm text-red-600">缺少 reimbursementId 参数。</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<section className="card-std">
|
||||||
|
<p className="text-sm text-slate-500">加载报销详情中...</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailQuery.isError || !detailQuery.data) {
|
||||||
|
const message = isApiError(detailQuery.error)
|
||||||
|
? `${detailQuery.error.code}: ${mapApiErrorMessage(
|
||||||
|
detailQuery.error.code,
|
||||||
|
detailQuery.error.details,
|
||||||
|
detailQuery.error.message,
|
||||||
|
)}`
|
||||||
|
: "加载报销详情失败";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card-std space-y-3">
|
||||||
|
<p className="text-sm text-red-600">{message}</p>
|
||||||
|
<Link className="btn-secondary" to="/reimbursements">
|
||||||
|
返回报销列表
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = detailQuery.data;
|
||||||
|
const canPay = hasPermission(permissions, "expense.finance.pay");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title={`报销详情 ${detail.formNumber ?? detail.id}`}
|
||||||
|
subtitle={`状态:${detail.status}`}
|
||||||
|
actions={
|
||||||
|
<Link className="btn-secondary" to="/reimbursements">
|
||||||
|
返回列表
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="card-std space-y-2 text-sm">
|
||||||
|
<div>报销ID:{detail.id}</div>
|
||||||
|
<div>报销日期:{detail.reimbursementDate ?? "-"}</div>
|
||||||
|
<div>金额:{detail.totalAmount ?? "-"}</div>
|
||||||
|
<div>提交人:{detail.submitter?.name ?? "-"}</div>
|
||||||
|
<div>报销人:{detail.owner?.name ?? "-"}</div>
|
||||||
|
<div>说明:{detail.description || "-"}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card-std space-y-3">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">可执行动作</h3>
|
||||||
|
|
||||||
|
{actionError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{actionError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={mutation.isPending || detail.status !== "draft"}
|
||||||
|
onClick={() => mutation.mutate("submit")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
提交报销
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
||||||
|
<p className="text-sm font-medium text-slate-800">撤回报销</p>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||||
|
onChange={(event) => setWithdrawComment(event.target.value)}
|
||||||
|
rows={2}
|
||||||
|
value={withdrawComment}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={mutation.isPending || detail.status !== "reviewing" || withdrawComment.trim().length < 1}
|
||||||
|
onClick={() => mutation.mutate("withdraw")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
撤回
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canPay ? (
|
||||||
|
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
||||||
|
<p className="text-sm font-medium text-slate-800">财务支付</p>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||||
|
onChange={(event) => setPaymentVoucherNo(event.target.value)}
|
||||||
|
placeholder="付款凭证号"
|
||||||
|
value={paymentVoucherNo}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||||
|
onChange={(event) => setPaymentDate(event.target.value)}
|
||||||
|
type="date"
|
||||||
|
value={paymentDate}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||||
|
onChange={(event) =>
|
||||||
|
setPaymentChannel(event.target.value as "bank" | "alipay" | "wechat" | "cash" | "other")
|
||||||
|
}
|
||||||
|
value={paymentChannel}
|
||||||
|
>
|
||||||
|
<option value="bank">bank</option>
|
||||||
|
<option value="alipay">alipay</option>
|
||||||
|
<option value="wechat">wechat</option>
|
||||||
|
<option value="cash">cash</option>
|
||||||
|
<option value="other">other</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={mutation.isPending || !["approved", "paid"].includes(detail.status)}
|
||||||
|
onClick={() => mutation.mutate("pay")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
执行支付
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card-std space-y-2">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">关联票据</h3>
|
||||||
|
{(detail.invoices ?? []).length ? (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
{detail.invoices.map((item) => (
|
||||||
|
<div className="rounded-lg border border-slate-200 px-3 py-2" key={item.id}>
|
||||||
|
<div className="font-medium text-slate-900">{item.id}</div>
|
||||||
|
<div className="text-slate-600">
|
||||||
|
状态 {item.status} / 发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500">暂无关联票据</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,192 @@
|
|||||||
import { EmptyStateCard } from "../components/common/EmptyStateCard";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { isApiError } from "../api/types";
|
||||||
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { hasPermission } from "../auth/permissions";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
export function ReimbursementsPage() {
|
export function ReimbursementsPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { permissions } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
|
||||||
|
const page = Number(searchParams.get("page") ?? "1") || 1;
|
||||||
|
const scope = (searchParams.get("scope") as "own" | "all" | null) ?? "own";
|
||||||
|
const canViewAll = hasPermission(permissions, "expense.view_all");
|
||||||
|
|
||||||
|
const listQuery = useQuery({
|
||||||
|
queryKey: ["reimbursements", "list", status, page, scope],
|
||||||
|
queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createReimbursement({}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="我的报销"
|
title="我的报销"
|
||||||
subtitle="此页面已完成路由、鉴权与菜单接入,后续将接入真实列表数据。"
|
subtitle="报销列表、详情与提交/撤回/支付动作入口。"
|
||||||
/>
|
actions={
|
||||||
<EmptyStateCard
|
<button
|
||||||
title="功能建设中"
|
className="btn-primary"
|
||||||
description="后续迭代将实现报销列表、筛选、详情与提交能力。"
|
disabled={createMutation.isPending}
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? "创建中..." : "新建报销"}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{createMutation.error && isApiError(createMutation.error) ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{createMutation.error.code}:
|
||||||
|
{mapApiErrorMessage(
|
||||||
|
createMutation.error.code,
|
||||||
|
createMutation.error.details,
|
||||||
|
createMutation.error.message,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="card-std space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{[
|
||||||
|
{ value: "draft", label: "草稿" },
|
||||||
|
{ value: "reviewing", label: "审批中" },
|
||||||
|
{ value: "approved", label: "已通过" },
|
||||||
|
{ value: "paid", label: "已支付" },
|
||||||
|
{ value: "rejected", label: "已驳回" },
|
||||||
|
{ value: "cancelled", label: "已取消" },
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
className={status === item.value ? "btn-primary" : "btn-secondary"}
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => setSearchParams({ status: item.value, page: "1", scope })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{canViewAll ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={scope === "own" ? "btn-primary" : "btn-secondary"}
|
||||||
|
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
仅自己
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={scope === "all" ? "btn-primary" : "btn-secondary"}
|
||||||
|
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
全范围
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
|
||||||
|
|
||||||
|
{listQuery.isError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{isApiError(listQuery.error)
|
||||||
|
? `${listQuery.error.code}: ${mapApiErrorMessage(
|
||||||
|
listQuery.error.code,
|
||||||
|
listQuery.error.details,
|
||||||
|
listQuery.error.message,
|
||||||
|
)}`
|
||||||
|
: "加载报销列表失败"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!listQuery.isLoading && !listQuery.isError ? (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-slate-200">
|
||||||
|
<table className="min-w-full text-left text-sm">
|
||||||
|
<thead className="bg-slate-50 text-slate-600">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2">单号</th>
|
||||||
|
<th className="px-3 py-2">状态</th>
|
||||||
|
<th className="px-3 py-2">金额</th>
|
||||||
|
<th className="px-3 py-2">报销人</th>
|
||||||
|
<th className="px-3 py-2">日期</th>
|
||||||
|
<th className="px-3 py-2">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(listQuery.data?.items ?? []).length ? (
|
||||||
|
listQuery.data?.items.map((item) => (
|
||||||
|
<tr className="border-t border-slate-100" key={item.id}>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="font-medium text-slate-900">{item.formNumber ?? item.id}</div>
|
||||||
|
<div className="text-xs text-slate-500">{item.id}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">{item.status}</td>
|
||||||
|
<td className="px-3 py-2">{item.totalAmount ?? "-"}</td>
|
||||||
|
<td className="px-3 py-2">{item.owner?.name ?? "-"}</td>
|
||||||
|
<td className="px-3 py-2">{item.reimbursementDate ?? "-"}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Link className="btn-secondary" to={`/reimbursements/${item.id}`}>
|
||||||
|
详情
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-3 text-slate-500" colSpan={6}>
|
||||||
|
暂无数据
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>
|
||||||
|
第 {page} / {totalPages} 页
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() =>
|
||||||
|
setSearchParams({ status, scope, page: String(Math.max(1, page - 1)) })
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() =>
|
||||||
|
setSearchParams({ status, scope, page: String(Math.min(totalPages, page + 1)) })
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { isApiError } from "../api/types";
|
||||||
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import {
|
||||||
|
approveWorkflowTask,
|
||||||
|
fetchWorkflowApprovers,
|
||||||
|
fetchWorkflowTaskDetail,
|
||||||
|
rejectWorkflowTask,
|
||||||
|
returnWorkflowTask,
|
||||||
|
transferWorkflowTask,
|
||||||
|
} from "../api/workflow";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { hasPermission } from "../auth/permissions";
|
||||||
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
|
||||||
|
export function WorkflowTaskDetailPage() {
|
||||||
|
const { taskId = "" } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { permissions } = useAuth();
|
||||||
|
|
||||||
|
const [rejectComment, setRejectComment] = useState("请补充必要说明后重提");
|
||||||
|
const [returnComment, setReturnComment] = useState("请补充报销说明并修正后重新提交");
|
||||||
|
const [transferReason, setTransferReason] = useState("当前事项需转交对应审批人处理,请尽快处理。");
|
||||||
|
const [selectedApprover, setSelectedApprover] = useState("");
|
||||||
|
const [actionError, setActionError] = useState("");
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
enabled: Boolean(taskId),
|
||||||
|
queryKey: ["workflow", "task", taskId],
|
||||||
|
queryFn: () => fetchWorkflowTaskDetail(taskId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const approverQuery = useQuery({
|
||||||
|
enabled: hasPermission(permissions, "expense.transfer"),
|
||||||
|
queryKey: ["workflow", "approvers"],
|
||||||
|
queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 50 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowedActions = useMemo(() => {
|
||||||
|
return new Set(detailQuery.data?.allowedActions ?? []);
|
||||||
|
}, [detailQuery.data?.allowedActions]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (action: "approve" | "reject" | "return" | "transfer") => {
|
||||||
|
if (!taskId) {
|
||||||
|
throw new Error("missing taskId");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "approve") {
|
||||||
|
return approveWorkflowTask(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "reject") {
|
||||||
|
return rejectWorkflowTask(taskId, rejectComment.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "return") {
|
||||||
|
return returnWorkflowTask(taskId, returnComment.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedApprover) {
|
||||||
|
throw new Error("请选择转审目标");
|
||||||
|
}
|
||||||
|
|
||||||
|
return transferWorkflowTask(taskId, selectedApprover, transferReason.trim());
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workflow", "task", taskId] }),
|
||||||
|
]);
|
||||||
|
setActionError("");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
if (error instanceof Error && !isApiError(error)) {
|
||||||
|
setActionError(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isApiError(error)) {
|
||||||
|
setActionError(
|
||||||
|
`${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionError("动作执行失败,请稍后重试");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!taskId) {
|
||||||
|
return (
|
||||||
|
<section className="card-std">
|
||||||
|
<p className="text-sm text-red-600">缺少 taskId 参数。</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<section className="card-std">
|
||||||
|
<p className="text-sm text-slate-500">加载任务详情中...</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailQuery.isError || !detailQuery.data) {
|
||||||
|
const message = isApiError(detailQuery.error)
|
||||||
|
? `${detailQuery.error.code}: ${mapApiErrorMessage(
|
||||||
|
detailQuery.error.code,
|
||||||
|
detailQuery.error.details,
|
||||||
|
detailQuery.error.message,
|
||||||
|
)}`
|
||||||
|
: "加载失败,请返回列表重试";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card-std space-y-3">
|
||||||
|
<p className="text-sm text-red-600">{message}</p>
|
||||||
|
<button className="btn-secondary" onClick={() => navigate("/workflow/tasks")}>返回待办列表</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = detailQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title={`任务详情 ${detail.task.stepName}`}
|
||||||
|
subtitle={`任务ID: ${detail.task.id}`}
|
||||||
|
actions={
|
||||||
|
<Link className="btn-secondary" to="/workflow/tasks">
|
||||||
|
返回待办列表
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="card-std space-y-2 text-sm">
|
||||||
|
<div>状态:{detail.task.status}</div>
|
||||||
|
<div>实例:{detail.workflowInstance?.id ?? "-"}</div>
|
||||||
|
<div>报销单号:{detail.reimbursement?.formNumber ?? "-"}</div>
|
||||||
|
<div>报销金额:{detail.reimbursement?.totalAmount ?? "-"}</div>
|
||||||
|
<div>提交人:{detail.reimbursement?.submitter?.name ?? "-"}</div>
|
||||||
|
<div>报销人:{detail.reimbursement?.owner?.name ?? "-"}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card-std space-y-3">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">审批动作</h3>
|
||||||
|
|
||||||
|
{actionError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{actionError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={!allowedActions.has("approve") || mutation.isPending}
|
||||||
|
onClick={() => mutation.mutate("approve")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
通过
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
||||||
|
<p className="text-sm font-medium text-slate-800">驳回</p>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||||
|
onChange={(event) => setRejectComment(event.target.value)}
|
||||||
|
rows={2}
|
||||||
|
value={rejectComment}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={!allowedActions.has("reject") || mutation.isPending || rejectComment.trim().length < 1}
|
||||||
|
onClick={() => mutation.mutate("reject")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
提交驳回
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
||||||
|
<p className="text-sm font-medium text-slate-800">退回</p>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||||
|
onChange={(event) => setReturnComment(event.target.value)}
|
||||||
|
rows={2}
|
||||||
|
value={returnComment}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={!allowedActions.has("return") || mutation.isPending || returnComment.trim().length < 1}
|
||||||
|
onClick={() => mutation.mutate("return")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
提交退回
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
||||||
|
<p className="text-sm font-medium text-slate-800">转审</p>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||||
|
onChange={(event) => setSelectedApprover(event.target.value)}
|
||||||
|
value={selectedApprover}
|
||||||
|
>
|
||||||
|
<option value="">请选择目标审批人</option>
|
||||||
|
{(approverQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name} ({item.employeeNo})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||||
|
onChange={(event) => setTransferReason(event.target.value)}
|
||||||
|
rows={2}
|
||||||
|
value={transferReason}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={
|
||||||
|
!allowedActions.has("transfer") ||
|
||||||
|
mutation.isPending ||
|
||||||
|
!selectedApprover ||
|
||||||
|
transferReason.trim().length < 10
|
||||||
|
}
|
||||||
|
onClick={() => mutation.mutate("transfer")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
提交转审
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card-std space-y-3">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">处理历史</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{detail.history.length ? (
|
||||||
|
detail.history.map((item) => (
|
||||||
|
<div className="rounded-lg border border-slate-200 px-3 py-2 text-sm" key={item.id}>
|
||||||
|
<div className="font-medium text-slate-900">
|
||||||
|
{item.action} · {item.actorName || item.actorId}
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-600">{item.comment || "-"}</div>
|
||||||
|
<div className="text-xs text-slate-500">{item.createdAt || "-"}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500">暂无历史记录</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,140 @@
|
|||||||
import { EmptyStateCard } from "../components/common/EmptyStateCard";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { isApiError } from "../api/types";
|
||||||
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { fetchWorkflowTasks } from "../api/workflow";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
export function WorkflowTasksPage() {
|
export function WorkflowTasksPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const status =
|
||||||
|
(searchParams.get("status") as
|
||||||
|
| "pending"
|
||||||
|
| "completed"
|
||||||
|
| "rejected"
|
||||||
|
| "cancelled"
|
||||||
|
| "skipped"
|
||||||
|
| null) ?? "pending";
|
||||||
|
const page = Number(searchParams.get("page") ?? "1") || 1;
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["workflow", "tasks", status, page],
|
||||||
|
queryFn: () => fetchWorkflowTasks({ status, page, pageSize: PAGE_SIZE }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader title="待我处理" subtitle="审批待办列表(支持筛选与详情动作联动)。" />
|
||||||
title="待我处理"
|
|
||||||
subtitle="此页面需要 expense.approve 权限,当前用于壳层联调与权限验证。"
|
<section className="card-std space-y-4">
|
||||||
/>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<EmptyStateCard
|
{[
|
||||||
title="功能建设中"
|
{ value: "pending", label: "待处理" },
|
||||||
description="后续迭代将接入待办任务列表与 approve/reject/return/transfer 动作。"
|
{ value: "completed", label: "已通过" },
|
||||||
/>
|
{ value: "rejected", label: "已驳回" },
|
||||||
|
{ value: "cancelled", label: "已取消" },
|
||||||
|
{ value: "skipped", label: "已跳过" },
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
className={status === item.value ? "btn-primary" : "btn-secondary"}
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => {
|
||||||
|
setSearchParams({ status: item.value, page: "1" });
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{query.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
|
||||||
|
|
||||||
|
{query.isError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{isApiError(query.error)
|
||||||
|
? `${query.error.code}: ${mapApiErrorMessage(
|
||||||
|
query.error.code,
|
||||||
|
query.error.details,
|
||||||
|
query.error.message,
|
||||||
|
)}`
|
||||||
|
: "加载待办失败,请稍后重试"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!query.isLoading && !query.isError ? (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-slate-200">
|
||||||
|
<table className="min-w-full text-left text-sm">
|
||||||
|
<thead className="bg-slate-50 text-slate-600">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2">任务</th>
|
||||||
|
<th className="px-3 py-2">报销ID</th>
|
||||||
|
<th className="px-3 py-2">状态</th>
|
||||||
|
<th className="px-3 py-2">创建时间</th>
|
||||||
|
<th className="px-3 py-2">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(query.data?.items ?? []).length ? (
|
||||||
|
query.data?.items.map((task) => (
|
||||||
|
<tr className="border-t border-slate-100" key={task.id}>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="font-medium text-slate-900">{task.stepName}</div>
|
||||||
|
<div className="text-xs text-slate-500">{task.id}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">{task.workflowInstance?.entityId}</td>
|
||||||
|
<td className="px-3 py-2">{task.status}</td>
|
||||||
|
<td className="px-3 py-2">{new Date(task.createdAt).toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Link className="btn-secondary" to={`/workflow/tasks/${task.id}`}>
|
||||||
|
详情
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-3 text-slate-500" colSpan={5}>
|
||||||
|
暂无数据
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>
|
||||||
|
第 {page} / {totalPages} 页
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setSearchParams({ status, page: String(Math.max(1, page - 1)) })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() =>
|
||||||
|
setSearchParams({ status, page: String(Math.min(totalPages, page + 1)) })
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 5_000,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -8,7 +8,9 @@ import { InvoicePoolPage } from "../pages/InvoicePoolPage";
|
|||||||
import { LoginPage } from "../pages/LoginPage";
|
import { LoginPage } from "../pages/LoginPage";
|
||||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||||
import { ProfilePage } from "../pages/ProfilePage";
|
import { ProfilePage } from "../pages/ProfilePage";
|
||||||
|
import { ReimbursementDetailPage } from "../pages/ReimbursementDetailPage";
|
||||||
import { ReimbursementsPage } from "../pages/ReimbursementsPage";
|
import { ReimbursementsPage } from "../pages/ReimbursementsPage";
|
||||||
|
import { WorkflowTaskDetailPage } from "../pages/WorkflowTaskDetailPage";
|
||||||
import { WorkflowTasksPage } from "../pages/WorkflowTasksPage";
|
import { WorkflowTasksPage } from "../pages/WorkflowTasksPage";
|
||||||
import { GuestOnly, RequireAuth, RequirePermission } from "./guards";
|
import { GuestOnly, RequireAuth, RequirePermission } from "./guards";
|
||||||
|
|
||||||
@@ -46,6 +48,15 @@ export function AppRouter() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="reimbursements/:reimbursementId"
|
||||||
|
element={
|
||||||
|
<RequirePermission anyOf={["expense.view_own", "expense.view_all"]}>
|
||||||
|
<ReimbursementDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="workflow/tasks"
|
path="workflow/tasks"
|
||||||
element={
|
element={
|
||||||
@@ -55,6 +66,15 @@ export function AppRouter() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="workflow/tasks/:taskId"
|
||||||
|
element={
|
||||||
|
<RequirePermission anyOf={["expense.approve"]}>
|
||||||
|
<WorkflowTaskDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="invoice-pool"
|
path="invoice-pool"
|
||||||
element={
|
element={
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户