1022 行
40 KiB
TypeScript
1022 行
40 KiB
TypeScript
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,
|
||
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 { PageHeader } from "../components/common/PageHeader";
|
||
import { Pagination } from "../components/common/Pagination";
|
||
import { StatusBadge } from "../components/common/StatusBadge";
|
||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||
import { ListWorkspace, PrimaryTable } 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";
|
||
|
||
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<LeaveRequestStatus | "">("");
|
||
const [scope, setScope] = useState<"own" | "all">("own");
|
||
const [page, setPage] = useState(1);
|
||
const [pageView, setPageView] = useState<LeavePageView>("records");
|
||
const [calendarMode, setCalendarMode] = useState<LeaveCalendarMode>("week");
|
||
const [calendarAnchorDate, setCalendarAnchorDate] = useState(() => new Date());
|
||
|
||
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 [attachmentInputKey, setAttachmentInputKey] = useState(0);
|
||
const [editingLeaveRequest, setEditingLeaveRequest] = useState<LeaveRequestItem | null>(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);
|
||
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);
|
||
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 = (
|
||
<SectionPanel title="请假记录" description="支持按状态和范围收敛列表,草稿与驳回单可再次提交。">
|
||
<div className="space-y-4">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div className="text-sm text-zinc-500">最近一次筛选会保留在当前页面状态中。</div>
|
||
<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 ? (
|
||
<>
|
||
<PrimaryTable>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>假种 / 申请人</TableHead>
|
||
<TableHead>时间段</TableHead>
|
||
<TableHead>天数</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead>附件</TableHead>
|
||
<TableHead>更新时间</TableHead>
|
||
<TableHead className="w-44">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{(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 (
|
||
<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"
|
||
variant="secondary"
|
||
type="button"
|
||
disabled={!canEdit || isSavingForm}
|
||
onClick={() => startEditing(item)}
|
||
>
|
||
编辑
|
||
</Button>
|
||
<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>
|
||
</PrimaryTable>
|
||
|
||
<Pagination
|
||
page={page}
|
||
totalPages={totalPages}
|
||
onPageChange={setPage}
|
||
testIdPrefix="leave-requests"
|
||
/>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</SectionPanel>
|
||
);
|
||
|
||
const leaveTeamCalendarPanel = (
|
||
<SectionPanel title="团队日历" description="按当前周/月窗口查看团队请假安排,帮助审批与排班判断。">
|
||
<div className="space-y-4">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div>
|
||
<div className="text-sm font-semibold text-zinc-900">{calendarWindow.title}</div>
|
||
<div className="text-xs text-zinc-500">
|
||
{useDepartmentCalendarScope ? "当前仅展示本部门窗口内请假记录;若超出 100 条会提示截断。" : "固定拉取当前可见窗口内的请假记录;若超出 100 条会提示截断。"}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2 md:items-end">
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
size="sm"
|
||
type="button"
|
||
variant="secondary"
|
||
data-testid="leave-calendar-prev"
|
||
onClick={() => setCalendarAnchorDate((current) => shiftCalendarAnchorDate(current, calendarMode, -1))}
|
||
>
|
||
上一个
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
type="button"
|
||
variant="secondary"
|
||
data-testid="leave-calendar-today"
|
||
onClick={() => setCalendarAnchorDate(new Date())}
|
||
>
|
||
今天
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
type="button"
|
||
variant="secondary"
|
||
data-testid="leave-calendar-next"
|
||
onClick={() => setCalendarAnchorDate((current) => shiftCalendarAnchorDate(current, calendarMode, 1))}
|
||
>
|
||
下一个
|
||
</Button>
|
||
</div>
|
||
|
||
<Tabs value={calendarMode} onValueChange={(value) => setCalendarMode(value as LeaveCalendarMode)} data-testid="leave-calendar-mode-tabs">
|
||
<TabsList className="flex w-full flex-wrap justify-start gap-1 md:w-auto">
|
||
<TabsTrigger value="week" data-testid="leave-calendar-mode-week">周视图</TabsTrigger>
|
||
<TabsTrigger value="month" data-testid="leave-calendar-mode-month">月视图</TabsTrigger>
|
||
</TabsList>
|
||
</Tabs>
|
||
</div>
|
||
</div>
|
||
|
||
{calendarIsTruncated ? (
|
||
<p
|
||
className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700"
|
||
data-testid="leave-team-calendar-truncation"
|
||
>
|
||
仅显示前 {calendarRequestsQuery.data?.items.length ?? 0} 条,共 {calendarRequestsQuery.data?.total ?? 0} 条。
|
||
</p>
|
||
) : null}
|
||
|
||
{calendarRequestsQuery.isLoading ? <TableSkeleton columns={7} rows={calendarMode === "week" ? 1 : 5} /> : null}
|
||
|
||
{calendarRequestsQuery.isError ? (
|
||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||
{resolveError(calendarRequestsQuery.error)}
|
||
</p>
|
||
) : null}
|
||
|
||
{!calendarRequestsQuery.isLoading && !calendarRequestsQuery.isError ? (
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-7 gap-3 text-xs font-medium text-zinc-500">
|
||
{WEEKDAY_LABELS.map((label) => (
|
||
<div className="px-1" key={label}>
|
||
{label}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-7 gap-3" data-testid="leave-team-calendar-grid">
|
||
{calendarWindow.dates.map((date) => {
|
||
const dateKey = toDateKey(date);
|
||
const items = calendarItemsByDate.get(dateKey) ?? [];
|
||
const isMuted = calendarMode === "month" && !isSameUtcMonth(date, calendarWindow.anchorMonth);
|
||
|
||
return (
|
||
<div
|
||
className={`min-h-36 rounded-xl border p-3 ${isMuted ? "border-zinc-200 bg-zinc-50/80 text-zinc-400" : "border-zinc-200 bg-white"}`}
|
||
data-testid="leave-team-calendar-cell"
|
||
key={dateKey}
|
||
>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className={`text-sm font-semibold ${isMuted ? "text-zinc-400" : "text-zinc-900"}`}>
|
||
{date.getUTCMonth() + 1}/{date.getUTCDate()}
|
||
</div>
|
||
<div className="text-[11px] text-zinc-500">{items.length ? `${items.length} 条` : ""}</div>
|
||
</div>
|
||
|
||
<div className="mt-3 space-y-2">
|
||
{items.map((item) => (
|
||
<div
|
||
className="rounded-lg border border-primary-100 bg-primary-50 px-2 py-1 text-xs text-primary-900"
|
||
data-testid="leave-team-calendar-entry"
|
||
key={`${dateKey}-${item.id}`}
|
||
>
|
||
<div className="font-medium">{formatCalendarEntryLabel(item, dateKey)}</div>
|
||
<div className="mt-1 text-[11px] text-primary-700">
|
||
{formatRangeLabel(item)} · {formatCalendarRequestedDays(item.requestedDays)} 天
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</SectionPanel>
|
||
);
|
||
|
||
return (
|
||
<ListWorkspace>
|
||
<PageHeader
|
||
title="请假申请"
|
||
subtitle="支持假种选择、半天粒度请假、额度冻结与撤回释放。"
|
||
/>
|
||
|
||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||
<MetricCard title="可用额度" value={balanceSummary.available.toFixed(1)} note="按当前启用假种汇总" />
|
||
<MetricCard title="已冻结" value={balanceSummary.reserved.toFixed(1)} note="审批中申请会占用额度" tone={balanceSummary.reserved > 0 ? "warning" : "neutral"} />
|
||
<MetricCard title="已使用" value={balanceSummary.used.toFixed(1)} note="通过后计入已使用" />
|
||
<MetricCard
|
||
title="本次预估"
|
||
value={effectiveRequestedDaysPreview ?? "-"}
|
||
note="创建草稿前可先预览天数"
|
||
tone={effectiveRequestedDaysPreview ? "warning" : "neutral"}
|
||
/>
|
||
</section>
|
||
|
||
<div className="page-shell-grid">
|
||
<SectionPanel title={formTitle} description={formDescription}>
|
||
<div className="space-y-4">
|
||
{formError ? (
|
||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||
{formError}
|
||
</p>
|
||
) : null}
|
||
|
||
{workingDaysPreviewQuery.isError && startDate && endDate ? (
|
||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||
工作日预览失败,提交时将以服务器结果为准
|
||
</p>
|
||
) : null}
|
||
|
||
{isNonWorkingPreview ? (
|
||
<p className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-700">
|
||
所选日期范围均为非工作日,无需提交请假
|
||
</p>
|
||
) : null}
|
||
|
||
{workingDaysPreviewQuery.data &&
|
||
!isNonWorkingPreview &&
|
||
workingDaysPreviewQuery.data.holidayDayCount > 0 ? (
|
||
<p className="rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-sm text-sky-700">
|
||
选择的日期范围内包含 {workingDaysPreviewQuery.data.holidayDayCount} 天法定假日,实际请假天数为{" "}
|
||
{workingDaysPreviewQuery.data.requestedDays} 天
|
||
</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
|
||
key={attachmentInputKey}
|
||
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>
|
||
{editingLeaveRequest ? (
|
||
<p className="text-xs text-zinc-500">
|
||
当前附件:{currentAttachmentLabel ?? "未上传"}。未重新选择文件时保留原附件。
|
||
</p>
|
||
) : null}
|
||
</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">{effectiveRequestedDaysPreview ?? "-"}</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{editingLeaveRequest ? (
|
||
<Button type="button" variant="secondary" onClick={resetForm} disabled={isSavingForm}>
|
||
取消编辑
|
||
</Button>
|
||
) : null}
|
||
<Button
|
||
type="button"
|
||
disabled={isSavingForm || isNonWorkingPreview}
|
||
onClick={() => {
|
||
if (editingLeaveRequest) {
|
||
updateMutation.mutate();
|
||
return;
|
||
}
|
||
createMutation.mutate();
|
||
}}
|
||
>
|
||
{editingLeaveRequest
|
||
? updateMutation.isPending
|
||
? "保存中..."
|
||
: "保存修改"
|
||
: createMutation.isPending
|
||
? "创建中..."
|
||
: "创建草稿"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</SectionPanel>
|
||
|
||
<SectionPanel title="额度概览" description="假种可用额度、冻结与已使用情况按自然周期汇总。">
|
||
<div className="space-y-4">
|
||
{balancesQuery.isLoading ? (
|
||
<div className="grid gap-4">
|
||
<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">
|
||
{(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="rounded-xl border border-dashed border-zinc-200 bg-zinc-50 px-4 py-8 text-sm text-zinc-500">
|
||
暂无可用假种,请联系 HR 初始化请假类型与策略。
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</SectionPanel>
|
||
</div>
|
||
|
||
{canViewTeamCalendar ? (
|
||
<Tabs data-testid="leave-page-view-tabs" value={pageView} onValueChange={(value) => setPageView(value as LeavePageView)}>
|
||
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
||
<TabsTrigger data-testid="leave-view-records" value="records">请假记录</TabsTrigger>
|
||
<TabsTrigger data-testid="leave-view-calendar" value="calendar">团队日历</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="records">
|
||
{leaveRecordsPanel}
|
||
</TabsContent>
|
||
<TabsContent value="calendar">
|
||
{leaveTeamCalendarPanel}
|
||
</TabsContent>
|
||
</Tabs>
|
||
) : (
|
||
leaveRecordsPanel
|
||
)}
|
||
|
||
<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);
|
||
}}
|
||
/>
|
||
</ListWorkspace>
|
||
);
|
||
}
|