feat(client): 行政代提交/代上传 owner 选择入口(F33)

- 报销创建支持选择 ownerEmployeeId(delegation-grants received)
- 票据上传支持 owner_employee_id(delegation-grants received)
- 保持普通员工流程不变,权限不足由后端阻断
这个提交包含在:
2026-02-09 18:32:38 +08:00
父节点 504ff02dd5
当前提交 b341e2fa63
共修改 5 个文件,包含 97 行新增和 14 行删除
+16 -1
查看文件
@@ -12,6 +12,22 @@ export type DelegationItem = {
revokedAt: string | null;
createdAt: string;
status: DelegationStatus;
principal?: {
id: string;
employeeNo: string;
name: string;
departmentId: string;
status: string;
roleCodes: string[];
} | null;
delegate?: {
id: string;
employeeNo: string;
name: string;
departmentId: string;
status: string;
roleCodes: string[];
} | null;
};
export type CreateDelegationRequest = {
@@ -56,4 +72,3 @@ export async function revokeDelegation(delegationId: string) {
},
);
}
+4 -1
查看文件
@@ -96,12 +96,15 @@ export async function fetchInvoicePoolDetail(invoiceId: string) {
return apiRequestData<InvoicePoolDetail>(`/api/invoice-pool/${invoiceId}`);
}
export async function uploadInvoice(file: File, ocrType?: string) {
export async function uploadInvoice(file: File, ocrType?: string, ownerEmployeeId?: string) {
const formData = new FormData();
formData.append("file", file);
if (ocrType) {
formData.append("ocr_type", ocrType);
}
if (ownerEmployeeId) {
formData.append("owner_employee_id", ownerEmployeeId);
}
return apiRequestData<{ invoicePoolId: string; status: string; fileSha256: string }>(
"/api/invoice-pool/upload",
+1
查看文件
@@ -160,6 +160,7 @@ export async function fetchReimbursementDetail(reimbursementId: string) {
}
export async function createReimbursement(payload: {
ownerEmployeeId?: string;
reimbursementDate?: string;
description?: string;
} = {}) {
+30 -2
查看文件
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
import {
bindInvoice,
fetchInvoicePoolDetail,
@@ -22,12 +23,16 @@ const PAGE_SIZE = 20;
export function InvoicePoolPage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const { permissions, me } = useAuth();
const canManageInvoice = hasPermission(permissions, "invoice.manage");
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
const isAdministration = (me?.roleCodes ?? []).includes("administration");
const showProxyOwnerSelector = canProxySubmit && isAdministration;
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 [uploadOwnerEmployeeId, setUploadOwnerEmployeeId] = useState("");
const [selectedInvoiceId, setSelectedInvoiceId] = useState("");
const [selectedReimbursementId, setSelectedReimbursementId] = useState("");
const [detailInvoiceId, setDetailInvoiceId] = useState("");
@@ -49,6 +54,12 @@ export function InvoicePoolPage() {
queryFn: () => fetchReimbursements({ page: 1, pageSize: 50, scope: "own" }),
});
const proxyDelegationsQuery = useQuery({
enabled: showProxyOwnerSelector,
queryKey: ["delegations", "mine", "active"],
queryFn: () => fetchMyDelegations("active"),
});
const detailQuery = useQuery({
enabled: Boolean(detailInvoiceId),
queryKey: ["invoice-pool", "detail", detailInvoiceId],
@@ -67,7 +78,7 @@ export function InvoicePoolPage() {
if (!selectedFile) {
throw new Error("请选择文件");
}
return uploadInvoice(selectedFile, "vat");
return uploadInvoice(selectedFile, "vat", uploadOwnerEmployeeId || undefined);
},
onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
@@ -151,6 +162,23 @@ export function InvoicePoolPage() {
<Card className="space-y-3 p-6">
<h3 className="text-base font-semibold text-slate-900">上传票据(VAT OCR)</h3>
{showProxyOwnerSelector ? (
<select
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="invoice-upload-owner"
onChange={(event) => setUploadOwnerEmployeeId(event.target.value)}
value={uploadOwnerEmployeeId}
>
<option value="">自己</option>
{(proxyDelegationsQuery.data?.received ?? [])
.filter((item) => item.status === "active")
.map((item) => (
<option key={item.id} value={item.principalEmployeeId}>
{item.principal?.name ?? item.principalEmployeeId}
</option>
))}
</select>
) : null}
<input
accept="application/pdf,image/jpeg,image/png,image/webp"
data-testid="invoice-upload-file"
+46 -10
查看文件
@@ -1,7 +1,9 @@
import { useState } from "react";
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 { fetchMyDelegations } from "../api/delegations";
import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
@@ -15,21 +17,35 @@ const PAGE_SIZE = 20;
export function ReimbursementsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const { permissions } = useAuth();
const { permissions, me } = 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 canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
const isAdministration = (me?.roleCodes ?? []).includes("administration");
const showProxyOwnerSelector = canProxySubmit && isAdministration;
const [proxyOwnerEmployeeId, setProxyOwnerEmployeeId] = useState("");
const listQuery = useQuery({
queryKey: ["reimbursements", "list", status, page, scope],
queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }),
});
const proxyDelegationsQuery = useQuery({
enabled: showProxyOwnerSelector,
queryKey: ["delegations", "mine", "active"],
queryFn: () => fetchMyDelegations("active"),
});
const createMutation = useMutation({
mutationFn: () => createReimbursement({}),
mutationFn: () =>
createReimbursement({
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
},
@@ -43,14 +59,34 @@ export function ReimbursementsPage() {
title="我的报销"
subtitle="报销列表、详情与提交/撤回/支付动作入口。"
actions={
<Button
data-testid="reimbursements-create"
disabled={createMutation.isPending}
onClick={() => createMutation.mutate()}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
{showProxyOwnerSelector ? (
<select
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="reimbursements-owner-select"
onChange={(event) => setProxyOwnerEmployeeId(event.target.value)}
value={proxyOwnerEmployeeId}
>
<option value="">自己</option>
{(proxyDelegationsQuery.data?.received ?? [])
.filter((item) => item.status === "active")
.map((item) => (
<option key={item.id} value={item.principalEmployeeId}>
{item.principal?.name ?? item.principalEmployeeId}
</option>
))}
</select>
) : null}
<Button
data-testid="reimbursements-create"
disabled={createMutation.isPending}
onClick={() => createMutation.mutate()}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
</div>
}
/>