441 行
16 KiB
TypeScript
441 行
16 KiB
TypeScript
import { useMemo, useRef, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import {
|
|
createLeaveRequest,
|
|
fetchLeaveBalances,
|
|
fetchLeaveRequests,
|
|
fetchLeaveTypes,
|
|
patchLeaveRequest,
|
|
submitLeaveRequest,
|
|
type LeaveRequestItem,
|
|
type LeaveRequestStatus,
|
|
type LeaveSession,
|
|
} from "../api/leave";
|
|
import { useAuth } from "../auth/AuthContext";
|
|
import { hasPermission } from "../auth/permissions";
|
|
import { PageHeader } from "../components/common/PageHeader";
|
|
import { Button } from "../components/ui/button";
|
|
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
|
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
|
|
|
type LeaveStatusFilter = "all" | LeaveRequestStatus;
|
|
|
|
const STATUS_TABS: Array<{ value: LeaveStatusFilter; label: string }> = [
|
|
{ value: "all", label: "全部" },
|
|
{ value: "draft", label: "草稿" },
|
|
{ value: "reviewing", label: "审批中" },
|
|
{ value: "approved", label: "已通过" },
|
|
];
|
|
|
|
const STATUS_LABEL: Record<LeaveRequestStatus, string> = {
|
|
draft: "草稿",
|
|
reviewing: "审批中",
|
|
approved: "已通过",
|
|
rejected: "已驳回",
|
|
cancelled: "已取消",
|
|
};
|
|
|
|
function todayDate() {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
function formatSession(value: LeaveSession) {
|
|
return value === "am" ? "上午" : "下午";
|
|
}
|
|
|
|
function formatLeaveRange(item: LeaveRequestItem) {
|
|
return `${item.startDate} ${formatSession(item.startSession)} - ${item.endDate} ${formatSession(item.endSession)}`;
|
|
}
|
|
|
|
function LeaveRecordCard({
|
|
item,
|
|
isSaving,
|
|
onEdit,
|
|
onSubmit,
|
|
}: {
|
|
item: LeaveRequestItem;
|
|
isSaving: boolean;
|
|
onEdit: (item: LeaveRequestItem) => void;
|
|
onSubmit: (item: LeaveRequestItem) => void;
|
|
}) {
|
|
const canEdit = item.status === "draft" || item.status === "rejected";
|
|
const canSubmit = item.status === "draft" || item.status === "rejected";
|
|
|
|
return (
|
|
<article className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-leave-record-card">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm font-semibold text-zinc-900">{item.leaveType?.name ?? "请假"}</div>
|
|
<div className="mt-1 text-xs text-zinc-500">{formatLeaveRange(item)}</div>
|
|
</div>
|
|
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
|
|
</div>
|
|
|
|
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
|
<div>
|
|
<div className="text-xs text-zinc-500">天数</div>
|
|
<div className="mt-1 text-lg font-semibold text-zinc-900">{item.requestedDays}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-zinc-500">附件</div>
|
|
<div className="mt-1 truncate text-zinc-900">{item.attachment?.originalName ?? "-"}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{item.reason ? <p className="mt-3 line-clamp-2 text-sm text-zinc-500">{item.reason}</p> : null}
|
|
|
|
{canEdit || canSubmit ? (
|
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
|
{canEdit ? (
|
|
<Button variant="secondary" type="button" disabled={isSaving} onClick={() => onEdit(item)}>
|
|
编辑
|
|
</Button>
|
|
) : null}
|
|
{canSubmit ? (
|
|
<Button type="button" disabled={isSaving} onClick={() => onSubmit(item)}>
|
|
提交
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
export function MobileLeavePage() {
|
|
const queryClient = useQueryClient();
|
|
const { permissions } = useAuth();
|
|
const canCreateLeave = hasPermission(permissions, "leave.create");
|
|
const [statusFilter, setStatusFilter] = useState<LeaveStatusFilter>("all");
|
|
const [editingItem, setEditingItem] = useState<LeaveRequestItem | null>(null);
|
|
const [leaveTypeId, setLeaveTypeId] = useState("");
|
|
const [startDate, setStartDate] = useState(todayDate);
|
|
const [endDate, setEndDate] = useState(todayDate);
|
|
const [startSession, setStartSession] = useState<LeaveSession>("am");
|
|
const [endSession, setEndSession] = useState<LeaveSession>("pm");
|
|
const [reason, setReason] = useState("");
|
|
const [attachmentFile, setAttachmentFile] = useState<File | null>(null);
|
|
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
const leaveTypesQuery = useQuery({
|
|
queryKey: ["mobile", "leave", "types"],
|
|
queryFn: () => fetchLeaveTypes({ enabled: true }),
|
|
});
|
|
|
|
const balancesQuery = useQuery({
|
|
queryKey: ["mobile", "leave", "balances"],
|
|
queryFn: () => fetchLeaveBalances(),
|
|
});
|
|
|
|
const requestsQuery = useQuery({
|
|
queryKey: ["mobile", "leave", "requests", statusFilter],
|
|
queryFn: () =>
|
|
fetchLeaveRequests({
|
|
status: statusFilter === "all" ? undefined : statusFilter,
|
|
scope: "own",
|
|
page: 1,
|
|
pageSize: 20,
|
|
}),
|
|
});
|
|
|
|
const invalidateLeaveQueries = async () => {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["mobile", "leave"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["leave"] }),
|
|
]);
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setEditingItem(null);
|
|
setLeaveTypeId("");
|
|
setStartDate(todayDate());
|
|
setEndDate(todayDate());
|
|
setStartSession("am");
|
|
setEndSession("pm");
|
|
setReason("");
|
|
setAttachmentFile(null);
|
|
if (attachmentInputRef.current) {
|
|
attachmentInputRef.current.value = "";
|
|
}
|
|
};
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: () =>
|
|
createLeaveRequest({
|
|
leaveTypeId,
|
|
startDate,
|
|
endDate,
|
|
startSession,
|
|
endSession,
|
|
reason,
|
|
file: attachmentFile,
|
|
}),
|
|
onSuccess: async () => {
|
|
toast.success("请假草稿已创建");
|
|
resetForm();
|
|
await invalidateLeaveQueries();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(resolveErrorMessage(error));
|
|
},
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: () => {
|
|
if (!editingItem) {
|
|
throw new Error("未选择请假单");
|
|
}
|
|
return patchLeaveRequest(editingItem.id, {
|
|
leaveTypeId,
|
|
startDate,
|
|
endDate,
|
|
startSession,
|
|
endSession,
|
|
reason,
|
|
file: attachmentFile,
|
|
});
|
|
},
|
|
onSuccess: async () => {
|
|
toast.success("请假单已保存");
|
|
resetForm();
|
|
await invalidateLeaveQueries();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(resolveErrorMessage(error));
|
|
},
|
|
});
|
|
|
|
const submitMutation = useMutation({
|
|
mutationFn: (item: LeaveRequestItem) => submitLeaveRequest(item.id),
|
|
onSuccess: async () => {
|
|
toast.success("请假单已提交");
|
|
await invalidateLeaveQueries();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(resolveErrorMessage(error));
|
|
},
|
|
});
|
|
|
|
const leaveTypes = leaveTypesQuery.data?.items ?? [];
|
|
const records = requestsQuery.data?.items ?? [];
|
|
const balances = balancesQuery.data?.items ?? [];
|
|
const balanceSummary = useMemo(
|
|
() =>
|
|
balances.reduce(
|
|
(summary, item) => ({
|
|
available: summary.available + item.available,
|
|
reserved: summary.reserved + item.reserved,
|
|
used: summary.used + item.used,
|
|
}),
|
|
{ available: 0, reserved: 0, used: 0 },
|
|
),
|
|
[balances],
|
|
);
|
|
const isSaving = createMutation.isPending || updateMutation.isPending || submitMutation.isPending;
|
|
|
|
const startEditing = (item: LeaveRequestItem) => {
|
|
setEditingItem(item);
|
|
setLeaveTypeId(item.leaveType?.id ?? "");
|
|
setStartDate(item.startDate);
|
|
setEndDate(item.endDate);
|
|
setStartSession(item.startSession);
|
|
setEndSession(item.endSession);
|
|
setReason(item.reason ?? "");
|
|
setAttachmentFile(null);
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
};
|
|
|
|
return (
|
|
<PageScaffold className="space-y-4">
|
|
<PageHeader title="请假" />
|
|
|
|
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-leave-summary">
|
|
<div className="grid grid-cols-3 gap-3 text-sm">
|
|
<div>
|
|
<div className="text-xs text-zinc-500">可用</div>
|
|
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.available.toFixed(1)}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-zinc-500">冻结</div>
|
|
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.reserved.toFixed(1)}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-zinc-500">已用</div>
|
|
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.used.toFixed(1)}</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-leave-form">
|
|
<div className="mb-4 flex items-center justify-between gap-3">
|
|
<h3 className="text-base font-semibold text-zinc-900">{editingItem ? "编辑请假" : "新建请假"}</h3>
|
|
{editingItem ? (
|
|
<Button variant="secondary" size="sm" type="button" onClick={resetForm}>
|
|
取消
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<label className="block space-y-2 text-sm text-zinc-700">
|
|
<span>假期类型</span>
|
|
<select className="select-std" value={leaveTypeId} onChange={(event) => setLeaveTypeId(event.target.value)}>
|
|
<option value="">请选择</option>
|
|
{leaveTypes.map((item) => (
|
|
<option key={item.id} value={item.id}>
|
|
{item.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<label className="block space-y-2 text-sm text-zinc-700">
|
|
<span>开始日期</span>
|
|
<input className="input-std" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} />
|
|
</label>
|
|
<label className="block space-y-2 text-sm text-zinc-700">
|
|
<span>结束日期</span>
|
|
<input className="input-std" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} />
|
|
</label>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<label className="block space-y-2 text-sm text-zinc-700">
|
|
<span>开始时段</span>
|
|
<select className="select-std" value={startSession} onChange={(event) => setStartSession(event.target.value as LeaveSession)}>
|
|
<option value="am">上午</option>
|
|
<option value="pm">下午</option>
|
|
</select>
|
|
</label>
|
|
<label className="block space-y-2 text-sm text-zinc-700">
|
|
<span>结束时段</span>
|
|
<select className="select-std" value={endSession} onChange={(event) => setEndSession(event.target.value as LeaveSession)}>
|
|
<option value="am">上午</option>
|
|
<option value="pm">下午</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<label className="block space-y-2 text-sm text-zinc-700">
|
|
<span>说明</span>
|
|
<textarea
|
|
className="input-std h-24 resize-none"
|
|
placeholder="请输入请假原因"
|
|
value={reason}
|
|
onChange={(event) => setReason(event.target.value)}
|
|
/>
|
|
</label>
|
|
|
|
<label className="block space-y-2 text-sm text-zinc-700">
|
|
<span>附件</span>
|
|
<input
|
|
ref={attachmentInputRef}
|
|
className="hidden"
|
|
type="file"
|
|
onChange={(event) => setAttachmentFile(event.target.files?.[0] ?? null)}
|
|
/>
|
|
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => attachmentInputRef.current?.click()}
|
|
>
|
|
选择附件
|
|
</Button>
|
|
<span className="min-w-0 flex-1 truncate text-sm text-zinc-500" data-testid="mobile-leave-attachment-name">
|
|
{attachmentFile?.name ?? "未选择文件"}
|
|
</span>
|
|
</div>
|
|
</label>
|
|
|
|
<Button
|
|
className="w-full"
|
|
type="button"
|
|
disabled={!canCreateLeave || isSaving || !leaveTypeId || !startDate || !endDate}
|
|
onClick={() => {
|
|
if (editingItem) {
|
|
updateMutation.mutate();
|
|
return;
|
|
}
|
|
createMutation.mutate();
|
|
}}
|
|
data-testid="mobile-leave-save"
|
|
>
|
|
{editingItem ? "保存修改" : "创建草稿"}
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-leave-balances">
|
|
<h3 className="text-base font-semibold text-zinc-900">额度</h3>
|
|
<div className="mt-3 space-y-2">
|
|
{balancesQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
|
|
{!balancesQuery.isLoading && !balances.length ? <div className="text-sm text-zinc-500">暂无额度</div> : null}
|
|
{balances.slice(0, 3).map((item) => (
|
|
<div key={item.leaveType.id} className="rounded-lg border border-zinc-200 p-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="font-medium text-zinc-900">{item.leaveType.name}</div>
|
|
<span className="badge-success">启用</span>
|
|
</div>
|
|
<div className="mt-3 grid grid-cols-3 gap-3 text-sm">
|
|
<div>
|
|
<div className="text-xs text-zinc-500">可用</div>
|
|
<div className="font-semibold text-zinc-900">{item.available}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-zinc-500">冻结</div>
|
|
<div className="font-semibold text-zinc-900">{item.reserved}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-zinc-500">已用</div>
|
|
<div className="font-semibold text-zinc-900">{item.used}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-leave-records">
|
|
<h3 className="text-base font-semibold text-zinc-900">请假记录</h3>
|
|
<Tabs value={statusFilter} onValueChange={(value) => setStatusFilter(value as LeaveStatusFilter)}>
|
|
<TabsList className="mt-3 grid h-auto w-full grid-cols-4">
|
|
{STATUS_TABS.map((item) => (
|
|
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-leave-tab-${item.value}`}>
|
|
{item.label}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
<div className="mt-4 space-y-3">
|
|
{requestsQuery.isLoading ? <div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">加载中...</div> : null}
|
|
{requestsQuery.isError ? (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
|
{resolveErrorMessage(requestsQuery.error)}
|
|
</div>
|
|
) : null}
|
|
{!requestsQuery.isLoading && !requestsQuery.isError && !records.length ? (
|
|
<div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">暂无请假记录</div>
|
|
) : null}
|
|
{records.map((item) => (
|
|
<LeaveRecordCard
|
|
item={item}
|
|
isSaving={isSaving}
|
|
key={item.id}
|
|
onEdit={startEditing}
|
|
onSubmit={(nextItem) => submitMutation.mutate(nextItem)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</PageScaffold>
|
|
);
|
|
}
|
|
|
|
export default MobileLeavePage;
|