feat(client): 行政代提交/代上传 owner 选择入口(F33)
- 报销创建支持选择 ownerEmployeeId(delegation-grants received) - 票据上传支持 owner_employee_id(delegation-grants received) - 保持普通员工流程不变,权限不足由后端阻断
这个提交包含在:
@@ -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) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户