feat(P15B-1,P15B-6): 完成请假V1闭环
这个提交包含在:
@@ -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>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户