文件
hyb-oa/client/src/pages/MobileLeavePage.tsx
T

591 行
22 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
createLeaveRequest,
fetchLeaveBalances,
fetchLeaveRequestDetail,
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 { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
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";
import {
formatApprovalHistoryTime,
resolveLeaveStatusHint,
workflowActionLabel,
} from "../lib/approvalDisplay";
type LeaveStatusFilter = "all" | LeaveRequestStatus;
type LeaveView = "form" | "records";
const STATUS_TABS: Array<{ value: LeaveStatusFilter; label: string }> = [
{ value: "all", label: "全部" },
{ value: "draft", label: "草稿" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
{ value: "rejected", label: "已驳回" },
];
const STATUS_LABEL: Record<LeaveRequestStatus, string> = {
draft: "草稿",
reviewing: "审批中",
approved: "已通过",
rejected: "已驳回",
cancelled: "已取消",
};
function leaveProgressFallback(status: LeaveRequestStatus | undefined) {
if (status === "draft") return "/mobile/approvals?progress=draft";
if (status === "approved") return "/mobile/approvals?progress=approved";
if (status === "rejected" || status === "cancelled") return "/mobile/approvals?progress=returned";
return "/mobile/approvals?progress=reviewing";
}
function isLeaveStatusFilter(value: string | null): value is LeaveStatusFilter {
return ["all", "draft", "reviewing", "approved", "rejected", "cancelled"].includes(value ?? "");
}
function resolveLeaveView(value: string | null): LeaveView {
return value === "form" ? "form" : "records";
}
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,
detailTo,
currentMobileSource,
showActions = true,
}: {
item: LeaveRequestItem;
isSaving: boolean;
onEdit: (item: LeaveRequestItem) => void;
onSubmit: (item: LeaveRequestItem) => void;
detailTo?: string;
currentMobileSource?: string;
showActions?: boolean;
}) {
const canEdit = item.status === "draft" || item.status === "rejected";
const canSubmit = item.status === "draft" || item.status === "rejected";
const approvalHistory = item.approvalHistory ?? [];
const content = (
<>
<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="mobile-info-grid mt-4 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}
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600" data-testid="mobile-leave-status-hint">
{resolveLeaveStatusHint(item)}
</div>
{approvalHistory.length ? (
<div className="mt-2 text-xs text-zinc-500">
最近审批:{workflowActionLabel(approvalHistory[0].action)}
{approvalHistory[0].comment ? ` · ${approvalHistory[0].comment}` : ""}
{" · "}
{formatApprovalHistoryTime(approvalHistory[0].createdAt)}
</div>
) : null}
</>
);
return (
<article className="mobile-card" data-testid="mobile-leave-record-card">
{detailTo ? (
<Link className="block" to={detailTo} state={{ from: currentMobileSource }}>
{content}
</Link>
) : content}
{showActions && (canEdit || canSubmit) ? (
<div className="mt-3 flex flex-wrap gap-2">
{canEdit ? (
<Button size="sm" variant="secondary" type="button" disabled={isSaving} onClick={() => onEdit(item)}>
编辑
</Button>
) : null}
{canSubmit ? (
<Button size="sm" type="button" disabled={isSaving} onClick={() => onSubmit(item)}>
提交
</Button>
) : null}
</div>
) : null}
</article>
);
}
export function MobileLeavePage() {
const queryClient = useQueryClient();
const location = useLocation();
const { leaveRequestId = "" } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const { permissions } = useAuth();
const canCreateLeave = hasPermission(permissions, "leave.create");
const [view, setView] = useState<LeaveView>(() => resolveLeaveView(searchParams.get("view")));
const [statusFilter, setStatusFilter] = useState<LeaveStatusFilter>(() => {
const queryStatus = searchParams.get("status");
return isLeaveStatusFilter(queryStatus) ? queryStatus : "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 currentMobileSource = useMemo(() => {
const params = new URLSearchParams(location.search);
params.set("view", view);
params.set("status", statusFilter);
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ""}`;
}, [location.pathname, location.search, statusFilter, view]);
useEffect(() => {
setView(resolveLeaveView(searchParams.get("view")));
const queryStatus = searchParams.get("status");
setStatusFilter(isLeaveStatusFilter(queryStatus) ? queryStatus : "all");
}, [searchParams]);
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 detailQuery = useQuery({
enabled: Boolean(leaveRequestId),
queryKey: ["mobile", "leave", "detail", leaveRequestId],
queryFn: () => fetchLeaveRequestDetail(leaveRequestId),
});
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) => {
setView("form");
const nextParams = new URLSearchParams(searchParams);
nextParams.set("view", "form");
nextParams.set("status", statusFilter);
setSearchParams(nextParams, { replace: true });
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" });
};
function handleViewChange(value: string) {
const nextView = resolveLeaveView(value);
setView(nextView);
const nextParams = new URLSearchParams(searchParams);
nextParams.set("view", nextView);
nextParams.set("status", statusFilter);
setSearchParams(nextParams, { replace: true });
}
function handleStatusChange(value: string) {
if (!isLeaveStatusFilter(value)) {
return;
}
setStatusFilter(value);
const nextParams = new URLSearchParams(searchParams);
nextParams.set("view", "records");
nextParams.set("status", value);
setSearchParams(nextParams, { replace: true });
}
if (leaveRequestId) {
const detail = detailQuery.data;
return (
<PageScaffold className="space-y-3 pb-4">
<MobileDetailHeader
title="请假详情"
subtitle={detail?.leaveType?.name ?? "请假"}
fallbackTo={leaveProgressFallback(detail?.status)}
status={detail ? <span className="badge-neutral">{STATUS_LABEL[detail.status]}</span> : null}
testId="mobile-leave-detail-header"
/>
{detailQuery.isLoading ? <div className="mobile-card text-sm text-zinc-500">加载中...</div> : null}
{detailQuery.isError ? (
<div className="mobile-card text-sm text-red-600">{resolveErrorMessage(detailQuery.error)}</div>
) : null}
{detailQuery.data ? (
<LeaveRecordCard
item={detailQuery.data}
isSaving={false}
onEdit={startEditing}
onSubmit={(nextItem) => submitMutation.mutate(nextItem)}
showActions={false}
/>
) : null}
</PageScaffold>
);
}
return (
<PageScaffold className="space-y-3 pb-4">
<PageHeader title="请假" />
<section className="mobile-card">
<Tabs value={view} onValueChange={handleViewChange}>
<TabsList className="grid h-auto w-full grid-cols-2">
<TabsTrigger value="records" data-testid="mobile-leave-view-records">请假记录</TabsTrigger>
<TabsTrigger value="form" data-testid="mobile-leave-view-form">新建请假</TabsTrigger>
</TabsList>
</Tabs>
</section>
{view === "form" ? (
<>
<section className="mobile-card" data-testid="mobile-leave-summary">
<div className="mobile-metric-grid 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="mobile-card" 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-3">
<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="mobile-info-grid">
<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="mobile-info-grid">
<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="min-h-12 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="mobile-card" 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="mobile-list-card">
<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="mobile-metric-grid mt-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>
</>
) : null}
{view === "records" ? (
<section className="mobile-card" data-testid="mobile-leave-records">
<h3 className="text-base font-semibold text-zinc-900">请假记录</h3>
<Tabs value={statusFilter} onValueChange={handleStatusChange}>
<TabsList className="mt-3 grid h-auto w-full grid-cols-5">
{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="mobile-empty-state">加载中...</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="mobile-empty-state">暂无请假记录</div>
) : null}
{records.map((item) => (
<LeaveRecordCard
currentMobileSource={currentMobileSource}
detailTo={`/mobile/leave/${encodeURIComponent(item.id)}`}
item={item}
isSaving={isSaving}
key={item.id}
onEdit={startEditing}
onSubmit={(nextItem) => submitMutation.mutate(nextItem)}
/>
))}
</div>
</section>
) : null}
</PageScaffold>
);
}
export default MobileLeavePage;