feat(P15B-1,P15B-6): 完成请假V1闭环

这个提交包含在:
2026-03-07 08:11:24 +08:00
父节点 03c5a78485
当前提交 4ba311199d
共修改 23 个文件,包含 3202 行新增和 308 行删除
+36
查看文件
@@ -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 同步功能未开启。";
}
+204
查看文件
@@ -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 }),
});
}
+25
查看文件
@@ -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: "待我处理",
+536
查看文件
@@ -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>
);
}
+61 -36
查看文件
@@ -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>
+76 -44
查看文件
@@ -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">
+23 -8
查看文件
@@ -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>
+10
查看文件
@@ -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={