R29-1 R29-6: 重构海音达工作台壳层与高频页面

这个提交包含在:
2026-03-23 09:59:35 +08:00
父节点 f9198a781a
当前提交 7e183c0cdc
共修改 33 个文件,包含 2193 行新增和 1564 行删除
+301 -272
查看文件
@@ -21,6 +21,9 @@ import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { ListWorkspace, PrimaryTable } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
@@ -129,6 +132,18 @@ export function LeaveRequestPage() {
return (leaveTypesQuery.data?.items ?? []).find((item) => item.id === leaveTypeId) ?? null;
}, [leaveTypeId, leaveTypesQuery.data?.items]);
const balanceSummary = useMemo(() => {
return (balancesQuery.data?.items ?? []).reduce(
(acc, item) => {
acc.available += item.available;
acc.reserved += item.reserved;
acc.used += item.used;
return acc;
},
{ available: 0, reserved: 0, used: 0 },
);
}, [balancesQuery.data?.items]);
const createMutation = useMutation({
mutationFn: async () => {
if (!leaveTypeId) {
@@ -218,303 +233,317 @@ export function LeaveRequestPage() {
const totalPages = Math.max(1, Math.ceil((requestsQuery.data?.total ?? 0) / PAGE_SIZE));
return (
<div className="space-y-6">
<ListWorkspace>
<PageHeader
title="请假申请"
subtitle="支持假种选择、半天粒度请假、额度冻结与撤回释放。"
/>
<div className="space-y-3">
<h3 className="text-base font-semibold text-zinc-900">额度概览</h3>
{balancesQuery.isLoading ? (
<div className="grid gap-4 md:grid-cols-3">
<CardSkeleton lines={4} />
<CardSkeleton lines={4} />
<CardSkeleton lines={4} />
</div>
) : null}
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard title="可用额度" value={balanceSummary.available.toFixed(1)} note="按当前启用假种汇总" />
<MetricCard title="已冻结" value={balanceSummary.reserved.toFixed(1)} note="审批中申请会占用额度" tone={balanceSummary.reserved > 0 ? "warning" : "neutral"} />
<MetricCard title="已使用" value={balanceSummary.used.toFixed(1)} note="通过后计入已使用" />
<MetricCard title="本次预估" value={requestedDaysPreview ?? "-"} note="创建草稿前可先预览天数" tone={requestedDaysPreview ? "warning" : "neutral"} />
</section>
{balancesQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(balancesQuery.error)}
</p>
) : null}
<div className="page-shell-grid">
<SectionPanel title="新建请假草稿" description="先填写时间段与假种,再补充附件和说明。">
<div className="space-y-4">
{formError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</p>
) : null}
{!balancesQuery.isLoading && !balancesQuery.isError ? (
<div className="grid gap-4 md:grid-cols-3">
{(balancesQuery.data?.items ?? []).length ? (
balancesQuery.data?.items.map((item) => (
<div className="card-std space-y-2 p-5" key={item.leaveType.id}>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-base font-semibold text-zinc-900">{item.leaveType.name}</p>
<p className="text-xs text-zinc-500">{item.periodStart} ~ {item.periodEnd}</p>
</div>
<StatusBadge status={item.leaveType.enabled ? "active" : "archived"} label={item.leaveType.enabled ? "启用" : "停用"} />
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-zinc-500">可用</p>
<p className="text-xl font-semibold text-zinc-900">{item.available}</p>
</div>
<div>
<p className="text-zinc-500">已冻结</p>
<p className="text-xl font-semibold text-zinc-900">{item.reserved}</p>
</div>
<div>
<p className="text-zinc-500">已使用</p>
<p className="text-xl font-semibold text-zinc-900">{item.used}</p>
</div>
<div>
<p className="text-zinc-500">授予额度</p>
<p className="text-xl font-semibold text-zinc-900">{item.policy.annualQuotaDays}</p>
</div>
</div>
</div>
))
) : (
<div className="card-std p-6 text-sm text-zinc-500 md:col-span-3">
暂无可用假种,请联系 HR 初始化请假类型与策略。
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="leave-type">假期类型</Label>
<select
className="select-std"
id="leave-type"
value={leaveTypeId}
onChange={(event) => setLeaveTypeId(event.target.value)}
>
<option value="">请选择</option>
{(leaveTypesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="leave-start-date">开始日期</Label>
<Input id="leave-start-date" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="leave-start-session">开始时段</Label>
<select
className="select-std"
id="leave-start-session"
value={startSession}
onChange={(event) => setStartSession(event.target.value as LeaveSession)}
>
<option value="am">上午</option>
<option value="pm">下午</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="leave-end-date">结束日期</Label>
<Input id="leave-end-date" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="leave-end-session">结束时段</Label>
<select
className="select-std"
id="leave-end-session"
value={endSession}
onChange={(event) => setEndSession(event.target.value as LeaveSession)}
>
<option value="am">上午</option>
<option value="pm">下午</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="leave-attachment">附件</Label>
<Input
id="leave-attachment"
type="file"
accept={selectedLeaveType?.attachmentMimeTypes?.join(",") || undefined}
onChange={(event) => {
const nextFile = event.target.files?.[0] ?? null;
setAttachmentFile(nextFile);
}}
/>
<p className="text-xs text-zinc-500">
{selectedLeaveType?.requiresAttachment
? `当前假种要求上传附件(允许:${selectedLeaveType.attachmentMimeTypes.join(", ") || "默认文档/图片"})`
: "附件为可选项"}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="leave-reason">请假说明</Label>
<Textarea
id="leave-reason"
rows={3}
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="请输入请假原因、备注或补充说明"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700">
<div>
预计请假天数:<span className="font-semibold text-zinc-900">{requestedDaysPreview ?? "-"}</span>
</div>
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
{createMutation.isPending ? "创建中..." : "创建草稿"}
</Button>
</div>
</div>
) : null}
</SectionPanel>
<SectionPanel title="额度概览" description="假种可用额度、冻结与已使用情况按自然周期汇总。">
<div className="space-y-4">
{balancesQuery.isLoading ? (
<div className="grid gap-4">
<CardSkeleton lines={4} />
<CardSkeleton lines={4} />
<CardSkeleton lines={4} />
</div>
) : null}
{balancesQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(balancesQuery.error)}
</p>
) : null}
{!balancesQuery.isLoading && !balancesQuery.isError ? (
<div className="grid gap-4">
{(balancesQuery.data?.items ?? []).length ? (
balancesQuery.data?.items.map((item) => (
<div className="card-std space-y-2 p-5" key={item.leaveType.id}>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-base font-semibold text-zinc-900">{item.leaveType.name}</p>
<p className="text-xs text-zinc-500">{item.periodStart} ~ {item.periodEnd}</p>
</div>
<StatusBadge status={item.leaveType.enabled ? "active" : "archived"} label={item.leaveType.enabled ? "启用" : "停用"} />
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-zinc-500">可用</p>
<p className="text-xl font-semibold text-zinc-900">{item.available}</p>
</div>
<div>
<p className="text-zinc-500">已冻结</p>
<p className="text-xl font-semibold text-zinc-900">{item.reserved}</p>
</div>
<div>
<p className="text-zinc-500">已使用</p>
<p className="text-xl font-semibold text-zinc-900">{item.used}</p>
</div>
<div>
<p className="text-zinc-500">授予额度</p>
<p className="text-xl font-semibold text-zinc-900">{item.policy.annualQuotaDays}</p>
</div>
</div>
</div>
))
) : (
<div className="rounded-xl border border-dashed border-zinc-200 bg-zinc-50 px-4 py-8 text-sm text-zinc-500">
暂无可用假种,请联系 HR 初始化请假类型与策略。
</div>
)}
</div>
) : null}
</div>
</SectionPanel>
</div>
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">新建请假草稿</h3>
{formError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</p>
) : null}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="leave-type">假期类型</Label>
<select
className="select-std"
id="leave-type"
value={leaveTypeId}
onChange={(event) => setLeaveTypeId(event.target.value)}
>
<option value="">请选择</option>
{(leaveTypesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="leave-start-date">开始日期</Label>
<Input id="leave-start-date" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="leave-start-session">开始时段</Label>
<select
className="select-std"
id="leave-start-session"
value={startSession}
onChange={(event) => setStartSession(event.target.value as LeaveSession)}
>
<option value="am">上午</option>
<option value="pm">下午</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="leave-end-date">结束日期</Label>
<Input id="leave-end-date" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="leave-end-session">结束时段</Label>
<select
className="select-std"
id="leave-end-session"
value={endSession}
onChange={(event) => setEndSession(event.target.value as LeaveSession)}
>
<option value="am">上午</option>
<option value="pm">下午</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="leave-attachment">附件</Label>
<Input
id="leave-attachment"
type="file"
accept={selectedLeaveType?.attachmentMimeTypes?.join(",") || undefined}
onChange={(event) => {
const nextFile = event.target.files?.[0] ?? null;
setAttachmentFile(nextFile);
}}
/>
<p className="text-xs text-zinc-500">
{selectedLeaveType?.requiresAttachment
? `当前假种要求上传附件(允许:${selectedLeaveType.attachmentMimeTypes.join(", ") || "默认文档/图片"})`
: "附件为可选项"}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="leave-reason">请假说明</Label>
<Textarea
id="leave-reason"
rows={3}
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="请输入请假原因、备注或补充说明"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700">
<div>
预计请假天数:<span className="font-semibold text-zinc-900">{requestedDaysPreview ?? "-"}</span>
</div>
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
{createMutation.isPending ? "创建中..." : "创建草稿"}
</Button>
</div>
</div>
<div className="card-std space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h3 className="text-base font-semibold text-zinc-900">请假记录</h3>
<div className="flex flex-wrap gap-2">
{canViewAll ? (
<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={scope}
value={statusFilter}
onChange={(event) => {
setScope(event.target.value as "own" | "all");
setStatusFilter(event.target.value as LeaveRequestStatus | "");
setPage(1);
}}
>
<option value="own">只看我的</option>
<option value="all">查看全员</option>
<option value="">全部状态</option>
<option value="draft">草稿</option>
<option value="reviewing">审批中</option>
<option value="approved">已通过</option>
<option value="rejected">已驳回</option>
<option value="cancelled">已取消</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>
<Button type="button" variant="secondary" onClick={() => queryClient.invalidateQueries({ queryKey: ["leave", "requests"] })}>
刷新
</Button>
</div>
</div>
</div>
{requestsQuery.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
{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.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 ? (
<div className="rounded-lg border border-zinc-200">
<Table>
<TableHeader>
<tr>
<TableHead>假种 / 申请人</TableHead>
<TableHead>时间段</TableHead>
<TableHead>天数</TableHead>
<TableHead>状态</TableHead>
<TableHead>附件</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-44">操作</TableHead>
</tr>
</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";
{!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>
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>
);
})
) : (
<TableRow>
<TableCell className="py-4 text-zinc-500" colSpan={7}>
暂无请假记录
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
) : null}
)}
</TableBody>
</Table>
</PrimaryTable>
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
testIdPrefix="leave-requests"
/>
</div>
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
testIdPrefix="leave-requests"
/>
</>
) : null}
</div>
</SectionPanel>
<ConfirmDialog
open={Boolean(withdrawTarget)}
@@ -531,6 +560,6 @@ export function LeaveRequestPage() {
await withdrawMutation.mutateAsync(withdrawTarget.id);
}}
/>
</div>
</ListWorkspace>
);
}