feat(P15B-1,P15B-6): 完成请假V1闭环
这个提交包含在:
@@ -136,6 +136,42 @@ export function mapApiErrorMessage(
|
||||
return "当前报销金额超出预算,系统已阻断提交。";
|
||||
}
|
||||
|
||||
if (details?.leave === "conflict_range") {
|
||||
return "当前时间段与已有请假记录冲突,请调整后再提交。";
|
||||
}
|
||||
|
||||
if (details?.leave === "insufficient_balance") {
|
||||
return "当前假种可用额度不足,无法提交。";
|
||||
}
|
||||
|
||||
if (details?.leave === "invalid_range") {
|
||||
return "请假开始/结束日期与半天时段不合法。";
|
||||
}
|
||||
|
||||
if (details?.leave === "missing_reserve_ledger") {
|
||||
return "请假冻结账本状态异常,请联系管理员处理。";
|
||||
}
|
||||
|
||||
if (details?.file === "required") {
|
||||
return "当前假种要求上传附件后才能提交。";
|
||||
}
|
||||
|
||||
if (details?.file === "unsupported_mime_type") {
|
||||
return "附件类型不被当前假种接受,请重新选择。";
|
||||
}
|
||||
|
||||
if (details?.withdraw === "no_pending_instance") {
|
||||
return "当前请假单不存在可撤回的审批实例。";
|
||||
}
|
||||
|
||||
if (details?.withdraw === "already_processed_by_approver") {
|
||||
return "审批人已处理该请假单,当前不能撤回。";
|
||||
}
|
||||
|
||||
if (details?.leaveTypeId === "missing_active_policy") {
|
||||
return "当前假种未配置可用策略,请联系 HR。";
|
||||
}
|
||||
|
||||
if (details?.integration === "erp_sync_disabled") {
|
||||
return "ERP 同步功能未开启。";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type LeaveSession = "am" | "pm";
|
||||
export type LeaveRequestStatus = "draft" | "reviewing" | "approved" | "rejected" | "cancelled";
|
||||
|
||||
export type LeaveType = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
requiresAttachment: boolean;
|
||||
attachmentMimeTypes: string[];
|
||||
sortOrder: number;
|
||||
policy: {
|
||||
id: string;
|
||||
annualQuotaDays: number;
|
||||
enabled: boolean;
|
||||
description: string | null;
|
||||
} | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type LeaveBalanceItem = {
|
||||
leaveType: LeaveType;
|
||||
policy: {
|
||||
id: string;
|
||||
annualQuotaDays: number;
|
||||
enabled: boolean;
|
||||
description: string | null;
|
||||
};
|
||||
granted: number;
|
||||
adjusted: number;
|
||||
reserved: number;
|
||||
used: number;
|
||||
available: number;
|
||||
periodStart: string | null;
|
||||
periodEnd: string | null;
|
||||
};
|
||||
|
||||
export type LeaveRequestItem = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
departmentId: string;
|
||||
status: LeaveRequestStatus;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
startSession: LeaveSession;
|
||||
endSession: LeaveSession;
|
||||
requestedDays: number;
|
||||
reason: string | null;
|
||||
attachment: {
|
||||
originalName: string | null;
|
||||
mimeType: string | null;
|
||||
} | null;
|
||||
leaveType: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
requiresAttachment: boolean;
|
||||
attachmentMimeTypes: string[];
|
||||
} | null;
|
||||
owner: {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
} | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type LeaveRequestListResponse = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
scope: "own" | "all";
|
||||
items: LeaveRequestItem[];
|
||||
};
|
||||
|
||||
export type LeaveBalanceResponse = {
|
||||
employeeId: string;
|
||||
items: LeaveBalanceItem[];
|
||||
};
|
||||
|
||||
export type LeaveListQuery = {
|
||||
status?: LeaveRequestStatus;
|
||||
scope?: "own" | "all";
|
||||
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 fetchLeaveTypes(params?: { enabled?: boolean }) {
|
||||
return apiRequestData<{ items: LeaveType[] }>(
|
||||
`/api/leave-types${toQueryString({ enabled: params?.enabled == null ? undefined : String(params.enabled) })}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createLeaveType(payload: {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
requiresAttachment?: boolean;
|
||||
attachmentMimeTypes?: string[];
|
||||
sortOrder?: number;
|
||||
annualQuotaDays: number;
|
||||
policyDescription?: string | null;
|
||||
}) {
|
||||
return apiRequestData<LeaveType>("/api/leave-types", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchLeaveType(
|
||||
leaveTypeId: string,
|
||||
payload: Partial<{
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
requiresAttachment: boolean;
|
||||
attachmentMimeTypes: string[];
|
||||
sortOrder: number;
|
||||
annualQuotaDays: number;
|
||||
policyDescription: string | null;
|
||||
}>,
|
||||
) {
|
||||
return apiRequestData<LeaveType>(`/api/leave-types/${leaveTypeId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchLeaveBalances(employeeId?: string) {
|
||||
return apiRequestData<LeaveBalanceResponse>(
|
||||
`/api/leave-balances${toQueryString({ employeeId })}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchLeaveRequests(query: LeaveListQuery = {}) {
|
||||
return apiRequestData<LeaveRequestListResponse>(
|
||||
`/api/leave-requests${toQueryString(query as Record<string, string | number | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchLeaveRequestDetail(leaveRequestId: string) {
|
||||
return apiRequestData<LeaveRequestItem>(`/api/leave-requests/${leaveRequestId}`);
|
||||
}
|
||||
|
||||
export async function createLeaveRequest(payload: {
|
||||
leaveTypeId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
startSession: LeaveSession;
|
||||
endSession: LeaveSession;
|
||||
reason?: string;
|
||||
file?: File | null;
|
||||
}) {
|
||||
const formData = new FormData();
|
||||
formData.append("leaveTypeId", payload.leaveTypeId);
|
||||
formData.append("startDate", payload.startDate);
|
||||
formData.append("endDate", payload.endDate);
|
||||
formData.append("startSession", payload.startSession);
|
||||
formData.append("endSession", payload.endSession);
|
||||
if (payload.reason?.trim()) {
|
||||
formData.append("reason", payload.reason.trim());
|
||||
}
|
||||
if (payload.file) {
|
||||
formData.append("file", payload.file);
|
||||
}
|
||||
|
||||
return apiRequestData<LeaveRequestItem>("/api/leave-requests", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitLeaveRequest(leaveRequestId: string) {
|
||||
return apiRequestData<Record<string, unknown>>(`/api/leave-requests/${leaveRequestId}/submit`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function withdrawLeaveRequest(leaveRequestId: string, comment?: string) {
|
||||
return apiRequestData<Record<string, unknown>>(`/api/leave-requests/${leaveRequestId}/withdraw`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ comment }),
|
||||
});
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export type WorkflowTaskDetail = {
|
||||
};
|
||||
workflowInstance: {
|
||||
id: string;
|
||||
entityType: string;
|
||||
currentStatus: string;
|
||||
currentStepId: string;
|
||||
startedAt: string | null;
|
||||
@@ -76,6 +77,30 @@ export type WorkflowTaskDetail = {
|
||||
name: string;
|
||||
} | null;
|
||||
} | null;
|
||||
leaveRequest: {
|
||||
id: string;
|
||||
status: string;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
startSession: "am" | "pm";
|
||||
endSession: "am" | "pm";
|
||||
requestedDays: number;
|
||||
reason: string | null;
|
||||
attachment: {
|
||||
originalName: string | null;
|
||||
mimeType: string | null;
|
||||
} | null;
|
||||
leaveType: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
} | null;
|
||||
owner: {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
} | null;
|
||||
} | null;
|
||||
allowedActions: Array<"approve" | "reject" | "return" | "transfer">;
|
||||
history: WorkflowHistoryItem[];
|
||||
};
|
||||
|
||||
+25
-10
@@ -1,4 +1,4 @@
|
||||
const EMPLOYEE_BASE_PERMISSIONS = [
|
||||
const EXPENSE_SELF_SERVICE_PERMISSIONS = [
|
||||
"expense.create",
|
||||
"expense.view_own",
|
||||
"expense.edit_own",
|
||||
@@ -10,6 +10,17 @@ const EMPLOYEE_BASE_PERMISSIONS = [
|
||||
"expense.proxy_manage",
|
||||
] as const;
|
||||
|
||||
const LEAVE_SELF_SERVICE_PERMISSIONS = [
|
||||
"leave.create",
|
||||
"leave.view_own",
|
||||
"leave.submit",
|
||||
] as const;
|
||||
|
||||
const EMPLOYEE_BASE_PERMISSIONS = [
|
||||
...EXPENSE_SELF_SERVICE_PERMISSIONS,
|
||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||
] as const;
|
||||
|
||||
const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
employee: EMPLOYEE_BASE_PERMISSIONS,
|
||||
manager: [
|
||||
@@ -17,6 +28,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
"expense.approve",
|
||||
"expense.transfer",
|
||||
"expense.view_all",
|
||||
"leave.approve",
|
||||
"report.dashboard.view",
|
||||
"budget.view",
|
||||
"budget.manage",
|
||||
@@ -26,6 +38,8 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
"expense.approve",
|
||||
"expense.transfer",
|
||||
"expense.view_all",
|
||||
"leave.approve",
|
||||
"leave.view_all",
|
||||
"report.dashboard.view",
|
||||
"budget.view",
|
||||
],
|
||||
@@ -57,16 +71,17 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
],
|
||||
administration: [
|
||||
"expense.proxy_submit",
|
||||
"expense.create",
|
||||
"expense.view_own",
|
||||
"expense.edit_own",
|
||||
"expense.delete_own",
|
||||
"expense.submit",
|
||||
"expense.withdraw",
|
||||
"invoice.upload",
|
||||
"invoice.view_own",
|
||||
...EMPLOYEE_BASE_PERMISSIONS,
|
||||
],
|
||||
hr: [
|
||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||
"leave.view_all",
|
||||
"leave.manage",
|
||||
"system.users",
|
||||
"system.users_sensitive",
|
||||
"expense.view_all",
|
||||
"report.dashboard.view",
|
||||
],
|
||||
hr: ["system.users", "system.users_sensitive", "expense.view_all", "report.dashboard.view"],
|
||||
admin: ["*"],
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
icon: "request_quote",
|
||||
anyOf: ["expense.view_own", "expense.view_all"],
|
||||
},
|
||||
{
|
||||
to: "/leave-requests",
|
||||
label: "请假申请",
|
||||
icon: "event_available",
|
||||
anyOf: ["leave.view_own", "leave.view_all"],
|
||||
},
|
||||
{
|
||||
to: "/workflow/tasks",
|
||||
label: "待我处理",
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import {
|
||||
createLeaveRequest,
|
||||
fetchLeaveBalances,
|
||||
fetchLeaveRequests,
|
||||
fetchLeaveTypes,
|
||||
submitLeaveRequest,
|
||||
withdrawLeaveRequest,
|
||||
type LeaveRequestStatus,
|
||||
type LeaveSession,
|
||||
} from "../api/leave";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
function resolveError(error: unknown) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "操作失败,请稍后重试。";
|
||||
}
|
||||
|
||||
function calculateRequestedDaysPreview(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
startSession: LeaveSession,
|
||||
endSession: LeaveSession,
|
||||
) {
|
||||
if (!startDate || !endDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const start = new Date(`${startDate}T00:00:00.000Z`);
|
||||
const end = new Date(`${endDate}T00:00:00.000Z`);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dayIndex = (value: Date) => Math.floor(value.getTime() / 86400000);
|
||||
const startSlot = dayIndex(start) * 2 + (startSession === "pm" ? 1 : 0);
|
||||
const endSlot = dayIndex(end) * 2 + (endSession === "pm" ? 1 : 0);
|
||||
if (endSlot < startSlot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number((((endSlot - startSlot + 1) / 2)).toFixed(1));
|
||||
}
|
||||
|
||||
function formatRangeLabel(item: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
startSession: LeaveSession;
|
||||
endSession: LeaveSession;
|
||||
}) {
|
||||
const sessionLabel = (session: LeaveSession) => (session === "am" ? "上午" : "下午");
|
||||
if (item.startDate === item.endDate && item.startSession === item.endSession) {
|
||||
return `${item.startDate} ${sessionLabel(item.startSession)}`;
|
||||
}
|
||||
return `${item.startDate} ${sessionLabel(item.startSession)} → ${item.endDate} ${sessionLabel(item.endSession)}`;
|
||||
}
|
||||
|
||||
export function LeaveRequestPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { me, permissions } = useAuth();
|
||||
|
||||
const canViewAll = hasPermission(permissions, "leave.view_all") || hasPermission(permissions, "leave.manage");
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<LeaveRequestStatus | "">("");
|
||||
const [scope, setScope] = useState<"own" | "all">("own");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [leaveTypeId, setLeaveTypeId] = useState("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [startSession, setStartSession] = useState<LeaveSession>("am");
|
||||
const [endSession, setEndSession] = useState<LeaveSession>("pm");
|
||||
const [reason, setReason] = useState("");
|
||||
const [attachmentFile, setAttachmentFile] = useState<File | null>(null);
|
||||
const [formError, setFormError] = useState("");
|
||||
const [withdrawTarget, setWithdrawTarget] = useState<{ id: string; label: string } | null>(null);
|
||||
|
||||
const requestedDaysPreview = useMemo(
|
||||
() => calculateRequestedDaysPreview(startDate, endDate, startSession, endSession),
|
||||
[endDate, endSession, startDate, startSession],
|
||||
);
|
||||
|
||||
const leaveTypesQuery = useQuery({
|
||||
queryKey: ["leave", "types"],
|
||||
queryFn: () => fetchLeaveTypes({ enabled: true }),
|
||||
});
|
||||
|
||||
const balancesQuery = useQuery({
|
||||
queryKey: ["leave", "balances", me?.id],
|
||||
enabled: Boolean(me?.id),
|
||||
queryFn: () => fetchLeaveBalances(),
|
||||
});
|
||||
|
||||
const requestsQuery = useQuery({
|
||||
queryKey: ["leave", "requests", statusFilter, scope, page],
|
||||
queryFn: () =>
|
||||
fetchLeaveRequests({
|
||||
status: statusFilter || undefined,
|
||||
scope: canViewAll ? scope : "own",
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
}),
|
||||
});
|
||||
|
||||
const selectedLeaveType = useMemo(() => {
|
||||
return (leaveTypesQuery.data?.items ?? []).find((item) => item.id === leaveTypeId) ?? null;
|
||||
}, [leaveTypeId, leaveTypesQuery.data?.items]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!leaveTypeId) {
|
||||
throw new Error("请选择假期类型");
|
||||
}
|
||||
if (!startDate || !endDate) {
|
||||
throw new Error("请选择开始和结束日期");
|
||||
}
|
||||
if (!selectedLeaveType) {
|
||||
throw new Error("假期类型不存在或已失效");
|
||||
}
|
||||
if (requestedDaysPreview == null || requestedDaysPreview <= 0) {
|
||||
throw new Error("请检查开始/结束日期与半天时段");
|
||||
}
|
||||
if (selectedLeaveType.requiresAttachment && !attachmentFile) {
|
||||
throw new Error("当前假种必须上传附件");
|
||||
}
|
||||
if (
|
||||
attachmentFile &&
|
||||
selectedLeaveType.attachmentMimeTypes.length > 0 &&
|
||||
!selectedLeaveType.attachmentMimeTypes.includes(attachmentFile.type)
|
||||
) {
|
||||
throw new Error("附件类型不符合当前假种要求");
|
||||
}
|
||||
|
||||
return createLeaveRequest({
|
||||
leaveTypeId,
|
||||
startDate,
|
||||
endDate,
|
||||
startSession,
|
||||
endSession,
|
||||
reason,
|
||||
file: attachmentFile,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
|
||||
]);
|
||||
setLeaveTypeId("");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setStartSession("am");
|
||||
setEndSession("pm");
|
||||
setReason("");
|
||||
setAttachmentFile(null);
|
||||
setFormError("");
|
||||
toast.success("请假草稿已创建");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveError(error);
|
||||
setFormError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: (leaveRequestId: string) => submitLeaveRequest(leaveRequestId),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
|
||||
]);
|
||||
toast.success("请假申请已提交");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const withdrawMutation = useMutation({
|
||||
mutationFn: (leaveRequestId: string) => withdrawLeaveRequest(leaveRequestId),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
|
||||
]);
|
||||
toast.success("请假申请已撤回");
|
||||
setWithdrawTarget(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((requestsQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="请假申请"
|
||||
subtitle="支持假种选择、半天粒度请假、额度冻结与撤回释放。"
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">额度概览</h3>
|
||||
{balancesQuery.isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<CardSkeleton lines={4} />
|
||||
<CardSkeleton lines={4} />
|
||||
<CardSkeleton lines={4} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{balancesQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveError(balancesQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!balancesQuery.isLoading && !balancesQuery.isError ? (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{(balancesQuery.data?.items ?? []).length ? (
|
||||
balancesQuery.data?.items.map((item) => (
|
||||
<div className="card-std space-y-2 p-5" key={item.leaveType.id}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-base font-semibold text-zinc-900">{item.leaveType.name}</p>
|
||||
<p className="text-xs text-zinc-500">{item.periodStart} ~ {item.periodEnd}</p>
|
||||
</div>
|
||||
<StatusBadge status={item.leaveType.enabled ? "active" : "archived"} label={item.leaveType.enabled ? "启用" : "停用"} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-zinc-500">可用</p>
|
||||
<p className="text-xl font-semibold text-zinc-900">{item.available}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">已冻结</p>
|
||||
<p className="text-xl font-semibold text-zinc-900">{item.reserved}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">已使用</p>
|
||||
<p className="text-xl font-semibold text-zinc-900">{item.used}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">授予额度</p>
|
||||
<p className="text-xl font-semibold text-zinc-900">{item.policy.annualQuotaDays}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="card-std p-6 text-sm text-zinc-500 md:col-span-3">
|
||||
暂无可用假种,请联系 HR 初始化请假类型与策略。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">新建请假草稿</h3>
|
||||
|
||||
{formError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{formError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-type">假期类型</Label>
|
||||
<select
|
||||
className="select-std"
|
||||
id="leave-type"
|
||||
value={leaveTypeId}
|
||||
onChange={(event) => setLeaveTypeId(event.target.value)}
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
{(leaveTypesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-start-date">开始日期</Label>
|
||||
<Input id="leave-start-date" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-start-session">开始时段</Label>
|
||||
<select
|
||||
className="select-std"
|
||||
id="leave-start-session"
|
||||
value={startSession}
|
||||
onChange={(event) => setStartSession(event.target.value as LeaveSession)}
|
||||
>
|
||||
<option value="am">上午</option>
|
||||
<option value="pm">下午</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-end-date">结束日期</Label>
|
||||
<Input id="leave-end-date" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-end-session">结束时段</Label>
|
||||
<select
|
||||
className="select-std"
|
||||
id="leave-end-session"
|
||||
value={endSession}
|
||||
onChange={(event) => setEndSession(event.target.value as LeaveSession)}
|
||||
>
|
||||
<option value="am">上午</option>
|
||||
<option value="pm">下午</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-attachment">附件</Label>
|
||||
<Input
|
||||
id="leave-attachment"
|
||||
type="file"
|
||||
accept={selectedLeaveType?.attachmentMimeTypes?.join(",") || undefined}
|
||||
onChange={(event) => {
|
||||
const nextFile = event.target.files?.[0] ?? null;
|
||||
setAttachmentFile(nextFile);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{selectedLeaveType?.requiresAttachment
|
||||
? `当前假种要求上传附件(允许:${selectedLeaveType.attachmentMimeTypes.join(", ") || "默认文档/图片"})`
|
||||
: "附件为可选项"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-reason">请假说明</Label>
|
||||
<Textarea
|
||||
id="leave-reason"
|
||||
rows={3}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="请输入请假原因、备注或补充说明"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700">
|
||||
<div>
|
||||
预计请假天数:<span className="font-semibold text-zinc-900">{requestedDaysPreview ?? "-"}</span>
|
||||
</div>
|
||||
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
|
||||
{createMutation.isPending ? "创建中..." : "创建草稿"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<h3 className="text-base font-semibold text-zinc-900">请假记录</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canViewAll ? (
|
||||
<select
|
||||
className="select-std"
|
||||
value={scope}
|
||||
onChange={(event) => {
|
||||
setScope(event.target.value as "own" | "all");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="own">只看我的</option>
|
||||
<option value="all">查看全员</option>
|
||||
</select>
|
||||
) : null}
|
||||
<select
|
||||
className="select-std"
|
||||
value={statusFilter}
|
||||
onChange={(event) => {
|
||||
setStatusFilter(event.target.value as LeaveRequestStatus | "");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="reviewing">审批中</option>
|
||||
<option value="approved">已通过</option>
|
||||
<option value="rejected">已驳回</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
<Button type="button" variant="secondary" onClick={() => queryClient.invalidateQueries({ queryKey: ["leave", "requests"] })}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requestsQuery.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
|
||||
|
||||
{requestsQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveError(requestsQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!requestsQuery.isLoading && !requestsQuery.isError ? (
|
||||
<div className="rounded-lg border border-zinc-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<TableHead>假种 / 申请人</TableHead>
|
||||
<TableHead>时间段</TableHead>
|
||||
<TableHead>天数</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>附件</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead className="w-44">操作</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(requestsQuery.data?.items ?? []).length ? (
|
||||
requestsQuery.data?.items.map((item) => {
|
||||
const isOwner = item.ownerId === me?.id;
|
||||
const canSubmit = isOwner && ["draft", "rejected"].includes(item.status);
|
||||
const canWithdraw = isOwner && item.status === "reviewing";
|
||||
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.leaveType?.name ?? "-"}</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{item.owner?.name ?? "-"}
|
||||
{item.owner?.employeeNo ? ` (${item.owner.employeeNo})` : ""}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatRangeLabel(item)}</TableCell>
|
||||
<TableCell>{item.requestedDays}</TableCell>
|
||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||
<TableCell className="text-xs text-zinc-500">
|
||||
{item.attachment?.originalName ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell>{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={!canSubmit || submitMutation.isPending}
|
||||
onClick={() => submitMutation.mutate(item.id)}
|
||||
>
|
||||
提交
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={!canWithdraw || withdrawMutation.isPending}
|
||||
onClick={() =>
|
||||
setWithdrawTarget({
|
||||
id: item.id,
|
||||
label: `${item.leaveType?.name ?? "请假"} · ${formatRangeLabel(item)}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
撤回
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="py-4 text-zinc-500" colSpan={7}>
|
||||
暂无请假记录
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
testIdPrefix="leave-requests"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(withdrawTarget)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setWithdrawTarget(null);
|
||||
}
|
||||
}}
|
||||
title="确认撤回请假申请?"
|
||||
description={withdrawTarget ? `将撤回:${withdrawTarget.label}` : undefined}
|
||||
confirmLabel="确认撤回"
|
||||
onConfirm={async () => {
|
||||
if (!withdrawTarget) return;
|
||||
await withdrawMutation.mutateAsync(withdrawTarget.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,16 @@ import { Textarea } from "../components/ui/textarea";
|
||||
|
||||
type MobileAction = "approve" | "reject" | "return" | "transfer";
|
||||
|
||||
function entityLabel(entityType: string | undefined) {
|
||||
if (entityType === "leave") {
|
||||
return "请假";
|
||||
}
|
||||
if (entityType === "reimbursement") {
|
||||
return "报销";
|
||||
}
|
||||
return entityType || "未知";
|
||||
}
|
||||
|
||||
export function MobileApprovalsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedTaskId, setSelectedTaskId] = useState("");
|
||||
@@ -97,6 +107,7 @@ export function MobileApprovalsPage() {
|
||||
|
||||
const pendingTasks = tasksQuery.data?.items ?? [];
|
||||
const selectedTask = detailQuery.data;
|
||||
const isLeaveTask = selectedTask?.workflowInstance?.entityType === "leave";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -130,7 +141,7 @@ export function MobileApprovalsPage() {
|
||||
>
|
||||
<div className="font-medium">{task.stepName}</div>
|
||||
<div className="text-xs text-zinc-500">任务ID: {task.id}</div>
|
||||
<div className="text-xs text-zinc-500">状态: {task.status}</div>
|
||||
<div className="text-xs text-zinc-500">业务: {entityLabel(task.workflowInstance?.entityType)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -150,9 +161,21 @@ export function MobileApprovalsPage() {
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<div>任务状态:{selectedTask.task.status}</div>
|
||||
<div>步骤:{selectedTask.task.stepName}</div>
|
||||
<div>报销单号:{selectedTask.reimbursement?.formNumber ?? "-"}</div>
|
||||
<div>金额:{selectedTask.reimbursement?.totalAmount ?? "-"}</div>
|
||||
<div>提交人:{selectedTask.reimbursement?.submitter?.name ?? "-"}</div>
|
||||
<div>对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
|
||||
{selectedTask.reimbursement ? (
|
||||
<>
|
||||
<div>报销单号:{selectedTask.reimbursement.formNumber ?? "-"}</div>
|
||||
<div>金额:{selectedTask.reimbursement.totalAmount ?? "-"}</div>
|
||||
<div>提交人:{selectedTask.reimbursement.submitter?.name ?? "-"}</div>
|
||||
</>
|
||||
) : null}
|
||||
{selectedTask.leaveRequest ? (
|
||||
<>
|
||||
<div>假期类型:{selectedTask.leaveRequest.leaveType?.name ?? "-"}</div>
|
||||
<div>申请人:{selectedTask.leaveRequest.owner?.name ?? "-"}</div>
|
||||
<div>请假天数:{selectedTask.leaveRequest.requestedDays}</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
@@ -192,40 +215,42 @@ export function MobileApprovalsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<p className="font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
data-testid="mobile-approvals-transfer-to"
|
||||
onChange={(event) => setTransferToApproverId(event.target.value)}
|
||||
value={transferToApproverId}
|
||||
>
|
||||
<option value="">请选择目标审批人</option>
|
||||
{(approversQuery.data?.items ?? []).map((approver) => (
|
||||
<option key={approver.id} value={approver.id}>
|
||||
{approver.name} ({approver.employeeNo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!isLeaveTask && selectedTask.allowedActions.includes("transfer") ? (
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<p className="font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
data-testid="mobile-approvals-transfer-to"
|
||||
onChange={(event) => setTransferToApproverId(event.target.value)}
|
||||
value={transferToApproverId}
|
||||
>
|
||||
<option value="">请选择目标审批人</option>
|
||||
{(approversQuery.data?.items ?? []).map((approver) => (
|
||||
<option key={approver.id} value={approver.id}>
|
||||
{approver.name} ({approver.employeeNo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Textarea
|
||||
data-testid="mobile-approvals-transfer-reason"
|
||||
onChange={(event) => setTransferReason(event.target.value)}
|
||||
placeholder="转审理由(至少10字)"
|
||||
rows={2}
|
||||
value={transferReason}
|
||||
/>
|
||||
<Textarea
|
||||
data-testid="mobile-approvals-transfer-reason"
|
||||
onChange={(event) => setTransferReason(event.target.value)}
|
||||
placeholder="转审理由(至少10字)"
|
||||
rows={2}
|
||||
value={transferReason}
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-testid="mobile-approvals-transfer"
|
||||
disabled={actionMutation.isPending || !selectedTask.allowedActions.includes("transfer")}
|
||||
onClick={() => actionMutation.mutate("transfer")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
提交转审
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="mobile-approvals-transfer"
|
||||
disabled={actionMutation.isPending || !selectedTask.allowedActions.includes("transfer")}
|
||||
onClick={() => actionMutation.mutate("transfer")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
提交转审
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,16 @@ import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
|
||||
function entityTitle(entityType: string | undefined) {
|
||||
if (entityType === "leave") {
|
||||
return "请假申请";
|
||||
}
|
||||
if (entityType === "reimbursement") {
|
||||
return "报销单";
|
||||
}
|
||||
return entityType || "业务单据";
|
||||
}
|
||||
|
||||
export function WorkflowTaskDetailPage() {
|
||||
const { taskId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -27,7 +37,7 @@ export function WorkflowTaskDetailPage() {
|
||||
const { permissions } = useAuth();
|
||||
|
||||
const [rejectComment, setRejectComment] = useState("请补充必要说明后重提");
|
||||
const [returnComment, setReturnComment] = useState("请补充报销说明并修正后重新提交");
|
||||
const [returnComment, setReturnComment] = useState("请补充必要信息并修正后重新提交");
|
||||
const [transferReason, setTransferReason] = useState("当前事项需转交对应审批人处理,请尽快处理。");
|
||||
const [selectedApprover, setSelectedApprover] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
@@ -104,9 +114,7 @@ export function WorkflowTaskDetailPage() {
|
||||
}
|
||||
|
||||
if (detailQuery.isLoading) {
|
||||
return (
|
||||
<CardSkeleton lines={8} />
|
||||
);
|
||||
return <CardSkeleton lines={8} />;
|
||||
}
|
||||
|
||||
if (detailQuery.isError || !detailQuery.data) {
|
||||
@@ -133,6 +141,8 @@ export function WorkflowTaskDetailPage() {
|
||||
}
|
||||
|
||||
const detail = detailQuery.data;
|
||||
const isLeaveTask = detail.workflowInstance?.entityType === "leave";
|
||||
const canTransfer = allowedActions.has("transfer");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -149,10 +159,30 @@ export function WorkflowTaskDetailPage() {
|
||||
<div className="card-std space-y-2 p-6 text-sm" data-testid="workflow-task-summary">
|
||||
<div>状态:<StatusBadge status={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>
|
||||
<div>对象类型:{entityTitle(detail.workflowInstance?.entityType)}</div>
|
||||
{detail.reimbursement ? (
|
||||
<>
|
||||
<div>报销单号:{detail.reimbursement.formNumber ?? "-"}</div>
|
||||
<div>报销金额:{detail.reimbursement.totalAmount ?? "-"}</div>
|
||||
<div>提交人:{detail.reimbursement.submitter?.name ?? "-"}</div>
|
||||
<div>报销人:{detail.reimbursement.owner?.name ?? "-"}</div>
|
||||
</>
|
||||
) : null}
|
||||
{detail.leaveRequest ? (
|
||||
<>
|
||||
<div>假期类型:{detail.leaveRequest.leaveType?.name ?? "-"}</div>
|
||||
<div>申请人:{detail.leaveRequest.owner?.name ?? "-"}</div>
|
||||
<div>
|
||||
时间段:
|
||||
{detail.leaveRequest.startDate ?? "-"} {detail.leaveRequest.startSession === "am" ? "上午" : "下午"}
|
||||
{" → "}
|
||||
{detail.leaveRequest.endDate ?? "-"} {detail.leaveRequest.endSession === "am" ? "上午" : "下午"}
|
||||
</div>
|
||||
<div>请假天数:{detail.leaveRequest.requestedDays}</div>
|
||||
<div>说明:{detail.leaveRequest.reason ?? "-"}</div>
|
||||
<div>附件:{detail.leaveRequest.attachment?.originalName ?? "-"}</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
@@ -216,42 +246,44 @@ export function WorkflowTaskDetailPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||
<p className="text-sm font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="workflow-task-transfer-to-approver"
|
||||
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
|
||||
data-testid="workflow-task-transfer-reason"
|
||||
onChange={(event) => setTransferReason(event.target.value)}
|
||||
rows={2}
|
||||
value={transferReason}
|
||||
/>
|
||||
<Button
|
||||
data-testid="workflow-task-transfer"
|
||||
variant="secondary"
|
||||
disabled={
|
||||
!allowedActions.has("transfer") ||
|
||||
mutation.isPending ||
|
||||
!selectedApprover ||
|
||||
transferReason.trim().length < 10
|
||||
}
|
||||
onClick={() => mutation.mutate("transfer")}
|
||||
type="button"
|
||||
>
|
||||
提交转审
|
||||
</Button>
|
||||
</div>
|
||||
{canTransfer && !isLeaveTask ? (
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||
<p className="text-sm font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="workflow-task-transfer-to-approver"
|
||||
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
|
||||
data-testid="workflow-task-transfer-reason"
|
||||
onChange={(event) => setTransferReason(event.target.value)}
|
||||
rows={2}
|
||||
value={transferReason}
|
||||
/>
|
||||
<Button
|
||||
data-testid="workflow-task-transfer"
|
||||
variant="secondary"
|
||||
disabled={
|
||||
!allowedActions.has("transfer") ||
|
||||
mutation.isPending ||
|
||||
!selectedApprover ||
|
||||
transferReason.trim().length < 10
|
||||
}
|
||||
onClick={() => mutation.mutate("transfer")}
|
||||
type="button"
|
||||
>
|
||||
提交转审
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-3 p-6">
|
||||
|
||||
@@ -13,6 +13,16 @@ import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function entityLabel(entityType: string | undefined) {
|
||||
if (entityType === "leave") {
|
||||
return "请假";
|
||||
}
|
||||
if (entityType === "reimbursement") {
|
||||
return "报销";
|
||||
}
|
||||
return entityType || "-";
|
||||
}
|
||||
|
||||
export function WorkflowTasksPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -35,7 +45,7 @@ export function WorkflowTasksPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="待我处理" subtitle="审批待办列表(支持筛选与详情动作联动)。" />
|
||||
<PageHeader title="待我处理" subtitle="审批待办列表,覆盖报销与请假流程。" />
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<Tabs
|
||||
@@ -68,10 +78,10 @@ export function WorkflowTasksPage() {
|
||||
<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,
|
||||
)}`
|
||||
query.error.code,
|
||||
query.error.details,
|
||||
query.error.message,
|
||||
)}`
|
||||
: "加载待办失败,请稍后重试"}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -82,7 +92,7 @@ export function WorkflowTasksPage() {
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<TableHead>任务</TableHead>
|
||||
<TableHead>报销ID</TableHead>
|
||||
<TableHead>业务对象</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="w-24">操作</TableHead>
|
||||
@@ -96,8 +106,13 @@ export function WorkflowTasksPage() {
|
||||
<div className="font-medium text-zinc-900">{task.stepName}</div>
|
||||
<div className="text-xs text-zinc-500">{task.id}</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{task.workflowInstance?.entityId}
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">
|
||||
{entityLabel(task.workflowInstance?.entityType)}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-zinc-500">
|
||||
{task.workflowInstance?.entityId ?? "-"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell><StatusBadge status={task.status} /></TableCell>
|
||||
<TableCell>{new Date(task.createdAt).toLocaleString()}</TableCell>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { MobileApprovalsPage } from "../pages/MobileApprovalsPage";
|
||||
import { MobileCapturePage } from "../pages/MobileCapturePage";
|
||||
import { InvoicePoolPage } from "../pages/InvoicePoolPage";
|
||||
import { LoginPage } from "../pages/LoginPage";
|
||||
import { LeaveRequestPage } from "../pages/LeaveRequestPage";
|
||||
import { NotificationsPage } from "../pages/NotificationsPage";
|
||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||
import { ProfilePage } from "../pages/ProfilePage";
|
||||
@@ -87,6 +88,15 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="leave-requests"
|
||||
element={
|
||||
<RequirePermission anyOf={["leave.view_own", "leave.view_all"]}>
|
||||
<LeaveRequestPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="workflow/tasks"
|
||||
element={
|
||||
|
||||
在新工单中引用
屏蔽一个用户