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; revokedAt: string | null;
createdAt: string; createdAt: string;
status: DelegationStatus; 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 = { 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}`); 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(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
if (ocrType) { if (ocrType) {
formData.append("ocr_type", ocrType); formData.append("ocr_type", ocrType);
} }
if (ownerEmployeeId) {
formData.append("owner_employee_id", ownerEmployeeId);
}
return apiRequestData<{ invoicePoolId: string; status: string; fileSha256: string }>( return apiRequestData<{ invoicePoolId: string; status: string; fileSha256: string }>(
"/api/invoice-pool/upload", "/api/invoice-pool/upload",
+1
查看文件
@@ -160,6 +160,7 @@ export async function fetchReimbursementDetail(reimbursementId: string) {
} }
export async function createReimbursement(payload: { export async function createReimbursement(payload: {
ownerEmployeeId?: string;
reimbursementDate?: string; reimbursementDate?: string;
description?: string; description?: string;
} = {}) { } = {}) {
+30 -2
查看文件
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types"; import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages"; import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
import { import {
bindInvoice, bindInvoice,
fetchInvoicePoolDetail, fetchInvoicePoolDetail,
@@ -22,12 +23,16 @@ const PAGE_SIZE = 20;
export function InvoicePoolPage() { export function InvoicePoolPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { permissions } = useAuth(); const { permissions, me } = useAuth();
const canManageInvoice = hasPermission(permissions, "invoice.manage"); 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 [status, setStatus] = useState<InvoicePoolStatus | "">("");
const [ownerScope, setOwnerScope] = useState<"own" | "all">("own"); const [ownerScope, setOwnerScope] = useState<"own" | "all">("own");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadOwnerEmployeeId, setUploadOwnerEmployeeId] = useState("");
const [selectedInvoiceId, setSelectedInvoiceId] = useState(""); const [selectedInvoiceId, setSelectedInvoiceId] = useState("");
const [selectedReimbursementId, setSelectedReimbursementId] = useState(""); const [selectedReimbursementId, setSelectedReimbursementId] = useState("");
const [detailInvoiceId, setDetailInvoiceId] = useState(""); const [detailInvoiceId, setDetailInvoiceId] = useState("");
@@ -49,6 +54,12 @@ export function InvoicePoolPage() {
queryFn: () => fetchReimbursements({ page: 1, pageSize: 50, scope: "own" }), queryFn: () => fetchReimbursements({ page: 1, pageSize: 50, scope: "own" }),
}); });
const proxyDelegationsQuery = useQuery({
enabled: showProxyOwnerSelector,
queryKey: ["delegations", "mine", "active"],
queryFn: () => fetchMyDelegations("active"),
});
const detailQuery = useQuery({ const detailQuery = useQuery({
enabled: Boolean(detailInvoiceId), enabled: Boolean(detailInvoiceId),
queryKey: ["invoice-pool", "detail", detailInvoiceId], queryKey: ["invoice-pool", "detail", detailInvoiceId],
@@ -67,7 +78,7 @@ export function InvoicePoolPage() {
if (!selectedFile) { if (!selectedFile) {
throw new Error("请选择文件"); throw new Error("请选择文件");
} }
return uploadInvoice(selectedFile, "vat"); return uploadInvoice(selectedFile, "vat", uploadOwnerEmployeeId || undefined);
}, },
onSuccess: async (data) => { onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
@@ -151,6 +162,23 @@ export function InvoicePoolPage() {
<Card className="space-y-3 p-6"> <Card className="space-y-3 p-6">
<h3 className="text-base font-semibold text-slate-900">上传票据(VAT OCR)</h3> <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 <input
accept="application/pdf,image/jpeg,image/png,image/webp" accept="application/pdf,image/jpeg,image/png,image/webp"
data-testid="invoice-upload-file" data-testid="invoice-upload-file"
+46 -10
查看文件
@@ -1,7 +1,9 @@
import { useState } from "react";
import { Link, useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types"; import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages"; import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements"; import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions"; import { hasPermission } from "../auth/permissions";
@@ -15,21 +17,35 @@ const PAGE_SIZE = 20;
export function ReimbursementsPage() { export function ReimbursementsPage() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { permissions } = useAuth(); const { permissions, me } = useAuth();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft"; const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
const page = Number(searchParams.get("page") ?? "1") || 1; const page = Number(searchParams.get("page") ?? "1") || 1;
const scope = (searchParams.get("scope") as "own" | "all" | null) ?? "own"; const scope = (searchParams.get("scope") as "own" | "all" | null) ?? "own";
const canViewAll = hasPermission(permissions, "expense.view_all"); 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({ const listQuery = useQuery({
queryKey: ["reimbursements", "list", status, page, scope], queryKey: ["reimbursements", "list", status, page, scope],
queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }), queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }),
}); });
const proxyDelegationsQuery = useQuery({
enabled: showProxyOwnerSelector,
queryKey: ["delegations", "mine", "active"],
queryFn: () => fetchMyDelegations("active"),
});
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: () => createReimbursement({}), mutationFn: () =>
createReimbursement({
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
}),
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] }); await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
}, },
@@ -43,14 +59,34 @@ export function ReimbursementsPage() {
title="我的报销" title="我的报销"
subtitle="报销列表、详情与提交/撤回/支付动作入口。" subtitle="报销列表、详情与提交/撤回/支付动作入口。"
actions={ actions={
<Button <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
data-testid="reimbursements-create" {showProxyOwnerSelector ? (
disabled={createMutation.isPending} <select
onClick={() => createMutation.mutate()} className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
type="button" data-testid="reimbursements-owner-select"
> onChange={(event) => setProxyOwnerEmployeeId(event.target.value)}
{createMutation.isPending ? "创建中..." : "新建报销"} value={proxyOwnerEmployeeId}
</Button> >
<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>
} }
/> />