355 lines
10 KiB
TypeScript
355 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
}
|