import { useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { mapApiErrorMessage } from "../api/errorMessages"; import { createLeaveRequest, fetchLeaveBalances, fetchLeaveWorkingDaysPreview, fetchLeaveRequests, fetchLeaveTypes, patchLeaveRequest, submitLeaveRequest, withdrawLeaveRequest, type LeaveRequestItem, 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 { Pagination } from "../components/common/Pagination"; import { StatusBadge } from "../components/common/StatusBadge"; import { TableSkeleton } from "../components/common/TableSkeleton"; import { ListWorkspace, PrimaryTable, StatStrip, WorkspaceHeader } from "../components/patterns/ListWorkspace"; import { MetricCard } from "../components/patterns/MetricCard"; import { SectionPanel } from "../components/patterns/SectionPanel"; 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"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; import { formatApprovalHistoryTime, resolveLeaveStatusHint, workflowActionLabel, } from "../lib/approvalDisplay"; const PAGE_SIZE = 10; const CALENDAR_PAGE_SIZE = 100; const WEEKDAY_LABELS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]; type LeaveCalendarMode = "week" | "month"; type LeavePageView = "records" | "calendar"; function startOfUtcDay(value: Date) { return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())); } function addUtcDays(value: Date, days: number) { const next = startOfUtcDay(value); next.setUTCDate(next.getUTCDate() + days); return next; } function startOfUtcWeek(value: Date) { const normalized = startOfUtcDay(value); const weekday = normalized.getUTCDay(); const offset = weekday === 0 ? -6 : 1 - weekday; return addUtcDays(normalized, offset); } function startOfUtcMonth(value: Date) { return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), 1)); } function endOfUtcMonth(value: Date) { return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth() + 1, 0)); } function toDateKey(value: Date) { return startOfUtcDay(value).toISOString().slice(0, 10); } function isSameUtcMonth(left: Date, right: Date) { return left.getUTCFullYear() === right.getUTCFullYear() && left.getUTCMonth() === right.getUTCMonth(); } function formatCalendarRequestedDays(value: number) { return Number.isInteger(value) ? String(value) : value.toFixed(1); } function buildCalendarWindow(anchorDate: Date, mode: LeaveCalendarMode) { const safeAnchor = startOfUtcDay(anchorDate); if (mode === "week") { const start = startOfUtcWeek(safeAnchor); const dates = Array.from({ length: 7 }, (_, index) => addUtcDays(start, index)); return { title: `${toDateKey(start)} ~ ${toDateKey(dates[dates.length - 1])}`, dates, queryFrom: toDateKey(start), queryTo: toDateKey(dates[dates.length - 1]), anchorMonth: safeAnchor, }; } const monthStart = startOfUtcMonth(safeAnchor); const monthEnd = endOfUtcMonth(safeAnchor); const gridStart = startOfUtcWeek(monthStart); const gridEnd = addUtcDays(startOfUtcWeek(monthEnd), 6); const dates: Date[] = []; for (let cursor = gridStart; cursor <= gridEnd; cursor = addUtcDays(cursor, 1)) { dates.push(cursor); } return { title: `${safeAnchor.getUTCFullYear()}年${safeAnchor.getUTCMonth() + 1}月`, dates, queryFrom: toDateKey(gridStart), queryTo: toDateKey(gridEnd), anchorMonth: monthStart, }; } function shiftCalendarAnchorDate(anchorDate: Date, mode: LeaveCalendarMode, direction: -1 | 1) { if (mode === "week") { return addUtcDays(anchorDate, direction * 7); } return new Date(Date.UTC(anchorDate.getUTCFullYear(), anchorDate.getUTCMonth() + direction, 1)); } function leaveRequestIntersectsDate(item: LeaveRequestItem, dateKey: string) { return item.startDate <= dateKey && item.endDate >= dateKey; } function formatCalendarEntryLabel(item: LeaveRequestItem, dateKey: string) { const ownerName = item.owner?.name ?? "未知成员"; const leaveTypeName = item.leaveType?.name ?? "请假"; if (item.startDate === item.endDate) { if (item.startSession === item.endSession) { return `${ownerName} · ${leaveTypeName} · ${item.startSession === "am" ? "上午" : "下午"}`; } return `${ownerName} · ${leaveTypeName} · 全天`; } if (item.startDate === dateKey) { return `${ownerName} · ${leaveTypeName} · ${item.startSession === "pm" ? "下午开始" : "开始"}`; } if (item.endDate === dateKey) { return `${ownerName} · ${leaveTypeName} · ${item.endSession === "am" ? "上午结束" : "结束"}`; } return `${ownerName} · ${leaveTypeName}`; } 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 hasLeaveViewAll = hasPermission(permissions, "leave.view_all"); const hasLeaveManage = hasPermission(permissions, "leave.manage"); const canViewAll = hasLeaveViewAll || hasLeaveManage; const canViewTeamCalendar = hasLeaveViewAll; const useDepartmentCalendarScope = hasLeaveViewAll && !hasLeaveManage && Boolean(me?.departmentId); const [statusFilter, setStatusFilter] = useState(""); const [scope, setScope] = useState<"own" | "all">("own"); const [page, setPage] = useState(1); const [pageView, setPageView] = useState("records"); const [calendarMode, setCalendarMode] = useState("week"); const [calendarAnchorDate, setCalendarAnchorDate] = useState(() => new Date()); const [leaveTypeId, setLeaveTypeId] = useState(""); const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); const [startSession, setStartSession] = useState("am"); const [endSession, setEndSession] = useState("pm"); const [reason, setReason] = useState(""); const [attachmentFile, setAttachmentFile] = useState(null); const [attachmentInputKey, setAttachmentInputKey] = useState(0); const attachmentInputRef = useRef(null); const [editingLeaveRequest, setEditingLeaveRequest] = useState(null); const [formError, setFormError] = useState(""); const [withdrawTarget, setWithdrawTarget] = useState<{ id: string; label: string } | null>(null); const localRequestedDaysPreview = useMemo( () => calculateRequestedDaysPreview(startDate, endDate, startSession, endSession), [endDate, endSession, startDate, startSession], ); const workingDaysPreviewQuery = useQuery({ queryKey: [ "leave", "working-days-preview", me?.id ?? "anonymous", startDate, endDate, startSession, endSession, ], enabled: Boolean(startDate && endDate && localRequestedDaysPreview != null), retry: false, queryFn: () => fetchLeaveWorkingDaysPreview({ startDate, endDate, startSession, endSession, }), }); 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 calendarWindow = useMemo( () => buildCalendarWindow(calendarAnchorDate, calendarMode), [calendarAnchorDate, calendarMode], ); const calendarRequestsQuery = useQuery({ queryKey: [ "leave", "calendar", calendarMode, calendarAnchorDate.toISOString(), calendarWindow.queryFrom, calendarWindow.queryTo, useDepartmentCalendarScope ? me?.departmentId ?? "no-department" : "all", ], enabled: canViewTeamCalendar && pageView === "calendar", queryFn: () => fetchLeaveRequests({ scope: "all", page: 1, pageSize: CALENDAR_PAGE_SIZE, dateFrom: calendarWindow.queryFrom, dateTo: calendarWindow.queryTo, departmentId: useDepartmentCalendarScope ? me?.departmentId ?? undefined : undefined, }), }); const selectedLeaveType = useMemo(() => { return (leaveTypesQuery.data?.items ?? []).find((item) => item.id === leaveTypeId) ?? null; }, [leaveTypeId, leaveTypesQuery.data?.items]); const effectiveRequestedDaysPreview = workingDaysPreviewQuery.data?.requestedDays ?? localRequestedDaysPreview; const isNonWorkingPreview = Boolean(workingDaysPreviewQuery.data?.isNonWorkingRange); const balanceSummary = useMemo(() => { return (balancesQuery.data?.items ?? []).reduce( (acc, item) => { acc.available += item.available; acc.reserved += item.reserved; acc.used += item.used; return acc; }, { available: 0, reserved: 0, used: 0 }, ); }, [balancesQuery.data?.items]); const calendarItemsByDate = useMemo(() => { const items = calendarRequestsQuery.data?.items ?? []; return new Map( calendarWindow.dates.map((date) => { const dateKey = toDateKey(date); return [ dateKey, items.filter((item) => leaveRequestIntersectsDate(item, dateKey)), ] as const; }), ); }, [calendarRequestsQuery.data?.items, calendarWindow.dates]); function resetForm() { setEditingLeaveRequest(null); setLeaveTypeId(""); setStartDate(""); setEndDate(""); setStartSession("am"); setEndSession("pm"); setReason(""); setAttachmentFile(null); setAttachmentInputKey((current) => current + 1); if (attachmentInputRef.current) { attachmentInputRef.current.value = ""; } setFormError(""); } function startEditing(item: LeaveRequestItem) { setEditingLeaveRequest(item); setLeaveTypeId(item.leaveType?.id ?? ""); setStartDate(item.startDate); setEndDate(item.endDate); setStartSession(item.startSession); setEndSession(item.endSession); setReason(item.reason ?? ""); setAttachmentFile(null); setAttachmentInputKey((current) => current + 1); if (attachmentInputRef.current) { attachmentInputRef.current.value = ""; } setFormError(""); } function buildFormPayload({ requireAttachment }: { requireAttachment: boolean }) { if (!leaveTypeId) { throw new Error("请选择假期类型"); } if (!startDate || !endDate) { throw new Error("请选择开始和结束日期"); } if (!selectedLeaveType) { throw new Error("假期类型不存在或已失效"); } if (effectiveRequestedDaysPreview == null || effectiveRequestedDaysPreview <= 0) { throw new Error("请检查开始/结束日期与半天时段"); } if ( requireAttachment && selectedLeaveType.requiresAttachment && !attachmentFile && !editingLeaveRequest?.attachment ) { throw new Error("当前假种必须上传附件"); } if ( attachmentFile && selectedLeaveType.attachmentMimeTypes.length > 0 && !selectedLeaveType.attachmentMimeTypes.includes(attachmentFile.type) ) { throw new Error("附件类型不符合当前假种要求"); } return { leaveTypeId, startDate, endDate, startSession, endSession, reason, file: attachmentFile, }; } const createMutation = useMutation({ mutationFn: async () => createLeaveRequest(buildFormPayload({ requireAttachment: true })), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }), queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }), queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }), ]); resetForm(); toast.success("请假草稿已创建"); }, onError: (error) => { const message = resolveError(error); setFormError(message); toast.error(message); }, }); const updateMutation = useMutation({ mutationFn: async () => { if (!editingLeaveRequest) { throw new Error("未选择可编辑的请假单"); } return patchLeaveRequest( editingLeaveRequest.id, buildFormPayload({ requireAttachment: false }), ); }, onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }), queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }), queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }), ]); resetForm(); 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", "calendar"] }), 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", "calendar"] }), 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)); const calendarIsTruncated = (calendarRequestsQuery.data?.total ?? 0) > (calendarRequestsQuery.data?.items.length ?? 0); const isSavingForm = createMutation.isPending || updateMutation.isPending; const formTitle = editingLeaveRequest ? "编辑请假单" : "新建请假草稿"; const formDescription = editingLeaveRequest ? "修改原单内容并保留同一请假单 ID,保存后仍可继续提交。" : "先填写时间段与假种,再补充附件和说明。"; const currentAttachmentLabel = attachmentFile?.name ?? (editingLeaveRequest?.attachment?.originalName ?? null); const leaveRecordsPanel = (
最近一次筛选会保留在当前页面状态中。
{canViewAll ? ( ) : null}
{requestsQuery.isLoading ? : null} {requestsQuery.isError ? (

{resolveError(requestsQuery.error)}

) : null} {!requestsQuery.isLoading && !requestsQuery.isError ? ( <> 假种 / 申请人 时间段 天数 状态 附件 更新时间 操作 {(requestsQuery.data?.items ?? []).length ? ( requestsQuery.data?.items.map((item) => { const isOwner = item.ownerId === me?.id; const canEdit = isOwner && ["draft", "rejected"].includes(item.status); const canSubmit = isOwner && ["draft", "rejected"].includes(item.status); const canWithdraw = isOwner && item.status === "reviewing"; return (
{item.leaveType?.name ?? "-"}
{item.owner?.name ?? "-"} {item.owner?.employeeNo ? ` (${item.owner.employeeNo})` : ""}
{formatRangeLabel(item)} {item.requestedDays}
{resolveLeaveStatusHint(item)}
{item.approvalHistory.length ? (
最近审批:{workflowActionLabel(item.approvalHistory[0].action)} {item.approvalHistory[0].comment ? ` · ${item.approvalHistory[0].comment}` : ""} {" · "} {formatApprovalHistoryTime(item.approvalHistory[0].createdAt)}
) : null}
{item.attachment?.originalName ?? "-"} {item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"}
); }) ) : ( 暂无请假记录 )}
) : null}
); const leaveTeamCalendarPanel = (
{calendarWindow.title}
{useDepartmentCalendarScope ? "当前仅展示本部门窗口内请假记录;若超出 100 条会提示截断。" : "固定拉取当前可见窗口内的请假记录;若超出 100 条会提示截断。"}
setCalendarMode(value as LeaveCalendarMode)} data-testid="leave-calendar-mode-tabs"> 周视图 月视图
{calendarIsTruncated ? (

仅显示前 {calendarRequestsQuery.data?.items.length ?? 0} 条,共 {calendarRequestsQuery.data?.total ?? 0} 条。

) : null} {calendarRequestsQuery.isLoading ? : null} {calendarRequestsQuery.isError ? (

{resolveError(calendarRequestsQuery.error)}

) : null} {!calendarRequestsQuery.isLoading && !calendarRequestsQuery.isError ? (
{WEEKDAY_LABELS.map((label) => (
{label}
))}
{calendarWindow.dates.map((date) => { const dateKey = toDateKey(date); const items = calendarItemsByDate.get(dateKey) ?? []; const isMuted = calendarMode === "month" && !isSameUtcMonth(date, calendarWindow.anchorMonth); return (
{date.getUTCMonth() + 1}/{date.getUTCDate()}
{items.length ? `${items.length} 条` : ""}
{items.map((item) => (
{formatCalendarEntryLabel(item, dateKey)}
{formatRangeLabel(item)} · {formatCalendarRequestedDays(item.requestedDays)} 天
))}
); })}
) : null}
); return ( 0 ? "warning" : "neutral"} />
{formError ? (

{formError}

) : null} {workingDaysPreviewQuery.isError && startDate && endDate ? (

工作日预览失败,提交时将以服务器结果为准

) : null} {isNonWorkingPreview ? (

所选日期范围均为非工作日,无需提交请假

) : null} {workingDaysPreviewQuery.data && !isNonWorkingPreview && workingDaysPreviewQuery.data.holidayDayCount > 0 ? (

选择的日期范围内包含 {workingDaysPreviewQuery.data.holidayDayCount} 天法定假日,实际请假天数为{" "} {workingDaysPreviewQuery.data.requestedDays} 天

) : null}
setStartDate(event.target.value)} />
setEndDate(event.target.value)} />
{ const nextFile = event.target.files?.[0] ?? null; setAttachmentFile(nextFile); }} />
{currentAttachmentLabel ?? "未选择文件"}

{selectedLeaveType?.requiresAttachment ? `当前假种要求上传附件(允许:${selectedLeaveType.attachmentMimeTypes.join(", ") || "默认文档/图片"})` : "附件为可选项"}

{editingLeaveRequest ? (

当前附件:{currentAttachmentLabel ?? "未上传"}。未重新选择文件时保留原附件。

) : null}