Files
Edu/apps/student-portal/src/app/leave/page.tsx

355 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* Leave — 在线请假(批次 A
*
* 数据源useMyLeaveRequests + useCreateLeaveRequest + useCancelLeaveRequest
* 视图:请假记录列表 + 新建请假表单react-hook-form + zod 校验)
*/
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
useMyLeaveRequests,
useCreateLeaveRequest,
useCancelLeaveRequest,
} from "@/lib/graphql/hooks";
import type { LeaveRequest, LeaveStatus, LeaveType } from "@/lib/graphql/types";
const leaveSchema = z.object({
leaveType: z.enum(["sick", "personal", "family", "other"]),
startDate: z.string().min(1, "请选择开始日期"),
endDate: z.string().min(1, "请选择结束日期"),
reason: z.string().min(5, "请填写请假原因(至少 5 字)"),
});
type LeaveFormData = z.infer<typeof leaveSchema>;
const LEAVE_TYPE_LABELS: Record<LeaveType, string> = {
sick: "病假",
personal: "事假",
family: "家事假",
other: "其他",
};
const LEAVE_STATUS_LABELS: Record<LeaveStatus, string> = {
pending: "待审批",
approved: "已批准",
rejected: "已驳回",
cancelled: "已撤销",
};
const LEAVE_STATUS_BADGE: Record<LeaveStatus, string> = {
pending: "badge-warning",
approved: "badge-success",
rejected: "badge-danger",
cancelled: "badge-info",
};
export default function LeavePage(): JSX.Element {
const { data, fetching, error, reexecute } = useMyLeaveRequests();
const [showForm, setShowForm] = useState(false);
const requests = data ?? [];
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-5xl mx-auto">
<header className="mb-6">
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</p>
<h1 className="mt-2 text-3xl lg:text-4xl font-serif" style={{ color: "var(--color-ink)" }}>
线
</h1>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</header>
<div className="rule-thin mb-8" />
<div className="mb-6">
<button
type="button"
onClick={() => setShowForm((v) => !v)}
className="btn-primary text-sm"
>
{showForm ? "取消" : "新建请假申请"}
</button>
</div>
{showForm && (
<LeaveForm
onSuccess={() => {
setShowForm(false);
reexecute();
}}
/>
)}
{fetching ? (
<LoadingSkeleton />
) : error ? (
<ErrorState message={error.message} onRetry={() => reexecute()} />
) : requests.length === 0 ? (
<EmptyState />
) : (
<ul className="space-y-4">
{requests.map((req) => (
<LeaveCard key={req.id} request={req} onCancelled={() => reexecute()} />
))}
</ul>
)}
</div>
);
}
function LeaveForm({ onSuccess }: { onSuccess: () => void }): JSX.Element {
const { execute, fetching, errors } = useCreateLeaveRequest();
const {
register,
handleSubmit,
reset,
formState: { errors: formErrors },
} = useForm<LeaveFormData>({
defaultValues: {
leaveType: "sick",
startDate: "",
endDate: "",
reason: "",
},
});
const onSubmit = (data: LeaveFormData): void => {
const result = leaveSchema.safeParse(data);
if (!result.success) return;
execute({
leaveType: result.data.leaveType,
startDate: result.data.startDate,
endDate: result.data.endDate,
reason: result.data.reason,
});
reset();
onSuccess();
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="card-paper p-6 mb-6 space-y-4"
>
<div>
<label
className="block text-xs uppercase tracking-wider mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<select
{...register("leaveType")}
className="w-full px-3 py-2 rounded-sm text-sm"
style={{
background: "var(--color-paper)",
border: "1px solid var(--color-rule)",
color: "var(--color-ink)",
}}
>
<option value="sick"></option>
<option value="personal"></option>
<option value="family"></option>
<option value="other"></option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label
className="block text-xs uppercase tracking-wider mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<input
type="date"
{...register("startDate", { required: "请选择开始日期" })}
className="w-full px-3 py-2 rounded-sm text-sm"
style={{
background: "var(--color-paper)",
border: "1px solid var(--color-rule)",
color: "var(--color-ink)",
}}
/>
{formErrors.startDate && (
<p className="mt-1 text-xs" style={{ color: "var(--color-danger)" }}>
{formErrors.startDate.message}
</p>
)}
</div>
<div>
<label
className="block text-xs uppercase tracking-wider mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<input
type="date"
{...register("endDate", { required: "请选择结束日期" })}
className="w-full px-3 py-2 rounded-sm text-sm"
style={{
background: "var(--color-paper)",
border: "1px solid var(--color-rule)",
color: "var(--color-ink)",
}}
/>
{formErrors.endDate && (
<p className="mt-1 text-xs" style={{ color: "var(--color-danger)" }}>
{formErrors.endDate.message}
</p>
)}
</div>
</div>
<div>
<label
className="block text-xs uppercase tracking-wider mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<textarea
{...register("reason", { required: "请填写请假原因", minLength: 5 })}
rows={3}
className="w-full px-3 py-2 rounded-sm text-sm"
style={{
background: "var(--color-paper)",
border: "1px solid var(--color-rule)",
color: "var(--color-ink)",
}}
/>
{formErrors.reason && (
<p className="mt-1 text-xs" style={{ color: "var(--color-danger)" }}>
{formErrors.reason.message ?? "请填写请假原因(至少 5 字)"}
</p>
)}
</div>
{errors.length > 0 && (
<p className="text-xs" style={{ color: "var(--color-danger)" }}>
{errors[0]?.message}
</p>
)}
<div className="flex gap-3">
<button type="submit" disabled={fetching} className="btn-primary text-sm disabled:opacity-50">
{fetching ? "提交中..." : "提交申请"}
</button>
</div>
</form>
);
}
function LeaveCard({
request,
onCancelled,
}: {
request: LeaveRequest;
onCancelled: () => void;
}): JSX.Element {
const { execute } = useCancelLeaveRequest();
const handleCancel = (): void => {
execute({ id: request.id });
onCancelled();
};
return (
<li>
<article className="card-paper p-5">
<div className="flex items-start justify-between gap-4 mb-2">
<div className="flex items-center gap-2">
<span className="badge badge-info">
{LEAVE_TYPE_LABELS[request.leaveType]}
</span>
<span className={`badge ${LEAVE_STATUS_BADGE[request.status]}`}>
{LEAVE_STATUS_LABELS[request.status]}
</span>
</div>
{request.status === "pending" && (
<button
type="button"
onClick={handleCancel}
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
</button>
)}
</div>
<p className="text-sm" style={{ color: "var(--color-ink)" }}>
{request.startDate} {request.endDate}
</p>
<p className="mt-1 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{request.reason}
</p>
{request.reviewComment && (
<p className="mt-2 text-xs italic" style={{ color: "var(--color-ink-subtle)" }}>
{request.reviewerName}{request.reviewComment}
</p>
)}
<p className="mt-2 text-xs" style={{ color: "var(--color-ink-muted)" }}>
{request.className} · {request.attendanceSynced ? "已同步" : "未同步"}
</p>
</article>
</li>
);
}
function LoadingSkeleton(): JSX.Element {
return (
<div className="space-y-4" aria-busy="true" aria-live="polite">
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-28 animate-pulse rounded-sm"
style={{ background: "var(--color-rule)", opacity: 0.3 }}
/>
))}
<span className="sr-only">...</span>
</div>
);
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }): JSX.Element {
return (
<div role="alert" className="card-paper p-8 text-center" style={{ borderColor: "var(--color-danger)" }}>
<p className="text-base font-serif" style={{ color: "var(--color-danger)" }}>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{message}
</p>
<button type="button" onClick={onRetry} className="mt-4 btn-secondary text-xs">
</button>
</div>
);
}
function EmptyState(): JSX.Element {
return (
<div className="card-paper p-8 text-center">
<p className="text-4xl font-serif" style={{ color: "var(--color-ink-subtle)" }} aria-hidden="true">
</p>
<p className="mt-4 text-base font-serif" style={{ color: "var(--color-ink)" }}>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
"新建请假申请"
</p>
</div>
);
}