R35-2 审批通知补结构化摘要
这个提交包含在:
@@ -88,6 +88,8 @@ export type LeaveListQuery = {
|
||||
scope?: "own" | "all";
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
};
|
||||
|
||||
function toQueryString(query: Record<string, string | number | undefined>) {
|
||||
|
||||
+381
-134
@@ -9,6 +9,7 @@ import {
|
||||
fetchLeaveTypes,
|
||||
submitLeaveRequest,
|
||||
withdrawLeaveRequest,
|
||||
type LeaveRequestItem,
|
||||
type LeaveRequestStatus,
|
||||
type LeaveSession,
|
||||
} from "../api/leave";
|
||||
@@ -29,8 +30,109 @@ 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 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)) {
|
||||
@@ -90,6 +192,9 @@ export function LeaveRequestPage() {
|
||||
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] = useState(() => new Date());
|
||||
|
||||
const [leaveTypeId, setLeaveTypeId] = useState("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
@@ -128,6 +233,24 @@ export function LeaveRequestPage() {
|
||||
}),
|
||||
});
|
||||
|
||||
const calendarWindow = useMemo(
|
||||
() => buildCalendarWindow(calendarAnchorDate, calendarMode),
|
||||
[calendarAnchorDate, calendarMode],
|
||||
);
|
||||
|
||||
const calendarRequestsQuery = useQuery({
|
||||
queryKey: ["leave", "calendar", calendarMode, calendarWindow.queryFrom, calendarWindow.queryTo],
|
||||
enabled: canViewAll && pageView === "calendar",
|
||||
queryFn: () =>
|
||||
fetchLeaveRequests({
|
||||
scope: "all",
|
||||
page: 1,
|
||||
pageSize: CALENDAR_PAGE_SIZE,
|
||||
dateFrom: calendarWindow.queryFrom,
|
||||
dateTo: calendarWindow.queryTo,
|
||||
}),
|
||||
});
|
||||
|
||||
const selectedLeaveType = useMemo(() => {
|
||||
return (leaveTypesQuery.data?.items ?? []).find((item) => item.id === leaveTypeId) ?? null;
|
||||
}, [leaveTypeId, leaveTypesQuery.data?.items]);
|
||||
@@ -144,6 +267,19 @@ export function LeaveRequestPage() {
|
||||
);
|
||||
}, [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]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!leaveTypeId) {
|
||||
@@ -182,6 +318,7 @@ export function LeaveRequestPage() {
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
|
||||
]);
|
||||
setLeaveTypeId("");
|
||||
@@ -206,6 +343,7 @@ export function LeaveRequestPage() {
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
|
||||
]);
|
||||
toast.success("请假申请已提交");
|
||||
@@ -220,6 +358,7 @@ export function LeaveRequestPage() {
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
|
||||
]);
|
||||
toast.success("请假申请已撤回");
|
||||
@@ -231,6 +370,232 @@ export function LeaveRequestPage() {
|
||||
});
|
||||
|
||||
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 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 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>
|
||||
</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">固定拉取当前可见窗口内的请假记录;若超出 100 条会提示截断。</div>
|
||||
</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>
|
||||
|
||||
{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>
|
||||
@@ -409,141 +774,23 @@ export function LeaveRequestPage() {
|
||||
</SectionPanel>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{canViewAll ? (
|
||||
<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>
|
||||
|
||||
{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 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>
|
||||
</PrimaryTable>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
testIdPrefix="leave-requests"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
<TabsContent value="records">
|
||||
{leaveRecordsPanel}
|
||||
</TabsContent>
|
||||
<TabsContent value="calendar">
|
||||
{leaveTeamCalendarPanel}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
leaveRecordsPanel
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(withdrawTarget)}
|
||||
|
||||
在新工单中引用
屏蔽一个用户