实现制度签收轻量回执(05-28-oa-policy-acknowledgement-v1)

这个提交包含在:
2026-05-28 11:48:37 +08:00
父节点 7c5b6d9578
当前提交 c80d9adbcd
共修改 16 个文件,包含 1806 行新增和 78 行删除
+122 -8
查看文件
@@ -1,21 +1,135 @@
import { useQuery } from "@tanstack/react-query";
import { fetchAnnouncements } from "../api/announcements";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { acknowledgeAnnouncement, fetchAnnouncements, type AnnouncementAcknowledgementStatus, type PortalAnnouncement } from "../api/announcements";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
function acknowledgementLabel(status: AnnouncementAcknowledgementStatus) {
if (status === "acknowledged") return "已签收";
if (status === "overdue") return "已逾期";
if (status === "pending") return "待签收";
return "普通公告";
}
function acknowledgementBadgeClass(status: AnnouncementAcknowledgementStatus) {
if (status === "acknowledged") return "badge-success";
if (status === "overdue") return "badge-error";
if (status === "pending") return "badge-warning";
return "badge-neutral";
}
function AnnouncementCard({
item,
checked,
submitting,
onCheckedChange,
onAcknowledge,
}: {
item: PortalAnnouncement;
checked: boolean;
submitting: boolean;
onCheckedChange: (checked: boolean) => void;
onAcknowledge: () => void;
}) {
const acknowledgement = item.acknowledgement ?? { status: "not_required", acknowledgedAt: null, overdue: false };
const requiresAcknowledgement = item.requiresAcknowledgement;
const isAcknowledged = acknowledgement.status === "acknowledged";
const canSubmit = requiresAcknowledgement && !isAcknowledged && checked && !submitting;
return (
<article
className={`card-std p-5 md:p-6 ${requiresAcknowledgement ? "border-l-4 border-l-[var(--color-accent-primary)]" : ""}`}
data-testid={`announcement-card-${item.id}`}
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold leading-7 text-zinc-900">{item.title}</h2>
<span className={acknowledgementBadgeClass(acknowledgement.status)}>
{acknowledgementLabel(acknowledgement.status)}
</span>
</div>
<p className="mt-2 text-sm leading-6 text-zinc-600">{item.summary}</p>
</div>
{item.acknowledgementDueAt ? (
<div className="shrink-0 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-zinc-600">
截止:{formatDisplayDate(item.acknowledgementDueAt)}
</div>
) : null}
</div>
{item.linkUrl ? (
<a className="mt-3 inline-flex text-sm font-medium text-primary hover:underline" href={item.linkUrl} target="_blank" rel="noreferrer">
查看链接
</a>
) : null}
{requiresAcknowledgement ? (
<div className="mt-4 rounded-lg border border-slate-200 bg-slate-50/70 p-4">
<div className="text-xs font-semibold text-zinc-500">签收声明</div>
<div className="mt-1 text-sm leading-6 text-zinc-800">{item.acknowledgementStatement}</div>
{isAcknowledged ? (
<div className="mt-3 text-sm text-emerald-700">
已于 {formatDisplayDateTime(acknowledgement.acknowledgedAt)} 完成本人签收。
</div>
) : (
<div className="mt-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<label className="flex min-w-0 items-start gap-2 text-sm leading-6 text-zinc-700">
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-zinc-300"
checked={checked}
onChange={(event) => onCheckedChange(event.target.checked)}
/>
<span>我已阅读并理解该制度内容,确认按公司制度执行。</span>
</label>
<Button type="button" className="w-full md:w-auto" disabled={!canSubmit} onClick={onAcknowledge}>
{submitting ? "提交中..." : acknowledgement.status === "overdue" ? "逾期补签" : "确认签收"}
</Button>
</div>
)}
</div>
) : null}
</article>
);
}
export function AnnouncementsPage() {
const queryClient = useQueryClient();
const [checkedById, setCheckedById] = useState<Record<string, boolean>>({});
const announcementsQuery = useQuery({ queryKey: ["announcements", "public"], queryFn: () => fetchAnnouncements(false) });
const acknowledgeMutation = useMutation({
mutationFn: acknowledgeAnnouncement,
onSuccess: async (_data, id) => {
setCheckedById((current) => ({ ...current, [id]: false }));
await queryClient.invalidateQueries({ queryKey: ["announcements"] });
toast.success("签收已记录");
},
onError: () => {
toast.error("签收失败,请刷新后重试");
},
});
return (
<div className="space-y-6">
<PageHeader title="公告" subtitle="全员查看公司通知、制度链接和 FAQ 入口。" />
<PageHeader title="公告" subtitle="查看公司通知、制度链接和需要本人确认的制度签收。" />
<div className="space-y-4">
{announcementsQuery.isLoading ? <div className="card-std p-6 text-sm text-zinc-500">公告加载中...</div> : null}
{(announcementsQuery.data?.items ?? []).map((item) => (
<div key={item.id} className="card-std p-6">
<div className="text-lg font-semibold text-zinc-900">{item.title}</div>
<div className="mt-2 text-sm text-zinc-600">{item.summary}</div>
{item.linkUrl ? <a className="mt-3 inline-block text-sm text-primary" href={item.linkUrl} target="_blank" rel="noreferrer">查看链接</a> : null}
</div>
<AnnouncementCard
key={item.id}
item={item}
checked={Boolean(checkedById[item.id])}
submitting={acknowledgeMutation.isPending && acknowledgeMutation.variables === item.id}
onCheckedChange={(checked) => setCheckedById((current) => ({ ...current, [item.id]: checked }))}
onAcknowledge={() => acknowledgeMutation.mutate(item.id)}
/>
))}
{!announcementsQuery.isLoading && !(announcementsQuery.data?.items ?? []).length ? (
<div className="card-std p-6 text-sm text-zinc-500">暂无公告</div>
) : null}
</div>
</div>
);
+178 -31
查看文件
@@ -1,11 +1,29 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { archiveAnnouncement, createAnnouncement, fetchAnnouncements, updateAnnouncement } from "../api/announcements";
import { toast } from "sonner";
import { archiveAnnouncement, createAnnouncement, fetchAnnouncements, updateAnnouncement, type AnnouncementCategory, type PortalAnnouncement } from "../api/announcements";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { toast } from "sonner";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { Textarea } from "../components/ui/textarea";
import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
function toChinaEndOfDayIso(value: string) {
if (!value) return null;
return new Date(`${value}T23:59:59+08:00`).toISOString();
}
function acknowledgementModeLabel(category: AnnouncementCategory, requiresAcknowledgement: boolean) {
return category === "policy" || requiresAcknowledgement ? "制度签收" : "普通公告";
}
function formatSummary(item: PortalAnnouncement) {
const summary = item.acknowledgementSummary;
if (!summary?.required) return "无需签收";
return `已签 ${summary.acknowledgedCount}/${summary.totalActiveEmployees} · 待签 ${summary.pendingCount}`;
}
export function PortalAnnouncementsPage() {
const queryClient = useQueryClient();
@@ -13,20 +31,39 @@ export function PortalAnnouncementsPage() {
const [summary, setSummary] = useState("");
const [linkUrl, setLinkUrl] = useState("");
const [priority, setPriority] = useState("100");
const [mode, setMode] = useState<AnnouncementCategory>("general");
const [acknowledgementStatement, setAcknowledgementStatement] = useState("我已阅读并理解该制度,确认按公司制度执行。");
const [acknowledgementDueDate, setAcknowledgementDueDate] = useState("");
const listQuery = useQuery({ queryKey: ["announcements", "manage"], queryFn: () => fetchAnnouncements(true) });
const refresh = () => queryClient.invalidateQueries({ queryKey: ["announcements"] });
const isPolicy = mode === "policy";
const createMutation = useMutation({
mutationFn: () => createAnnouncement({ title, summary, linkUrl: linkUrl || null, priority: Number(priority || 100) }),
mutationFn: () => createAnnouncement({
title,
summary,
linkUrl: linkUrl || null,
priority: Number(priority || 100),
category: mode,
requiresAcknowledgement: isPolicy,
acknowledgementStatement: isPolicy ? acknowledgementStatement.trim() : null,
acknowledgementDueAt: isPolicy ? toChinaEndOfDayIso(acknowledgementDueDate) : null,
}),
onSuccess: async () => {
setTitle("");
setSummary("");
setLinkUrl("");
setPriority("100");
setMode("general");
setAcknowledgementStatement("我已阅读并理解该制度,确认按公司制度执行。");
setAcknowledgementDueDate("");
await refresh();
toast.success("公告已创建");
},
onError: () => {
toast.error("公告创建失败,请检查填写内容");
},
});
const archiveMutation = useMutation({
@@ -45,39 +82,149 @@ export function PortalAnnouncementsPage() {
},
});
const handleCreate = () => {
if (!title.trim() || !summary.trim()) {
toast.error("请填写标题和摘要");
return;
}
if (isPolicy && !acknowledgementStatement.trim()) {
toast.error("制度签收公告必须填写签收声明");
return;
}
createMutation.mutate();
};
return (
<div className="space-y-6">
<PageHeader title="公告管理" subtitle="轻量发布公告、制度链接和 FAQ 入口。" />
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">新建公告</h3>
<div className="space-y-2"><Label>标题</Label><Input value={title} onChange={(event) => setTitle(event.target.value)} /></div>
<div className="space-y-2"><Label>摘要</Label><textarea className="min-h-28 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm" value={summary} onChange={(event) => setSummary(event.target.value)} /></div>
<div className="space-y-2"><Label>链接</Label><Input value={linkUrl} onChange={(event) => setLinkUrl(event.target.value)} /></div>
<div className="space-y-2"><Label>优先级</Label><Input value={priority} onChange={(event) => setPriority(event.target.value)} /></div>
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>{createMutation.isPending ? "创建中..." : "创建公告"}</Button>
</div>
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">公告列表</h3>
<div className="space-y-3">
{(listQuery.data?.items ?? []).map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="mt-1 text-sm text-zinc-600">{item.summary}</div>
<div className="mt-1 text-xs text-zinc-500">priority={item.priority} · {item.enabled ? "enabled" : "disabled"}</div>
</div>
<div className="flex gap-2">
<Button type="button" variant="secondary" onClick={() => toggleMutation.mutate({ id: item.id, enabled: !item.enabled })}>{item.enabled ? "停用" : "启用"}</Button>
<Button type="button" variant="secondary" onClick={() => archiveMutation.mutate(item.id)}>归档</Button>
</div>
</div>
<PageHeader title="公告管理" subtitle="发布普通公告,或创建需要全员显式确认的制度签收公告。" />
<div className="admin-workspace-grid xl:grid-cols-[360px,minmax(0,1fr)]">
<section className="admin-command-panel space-y-4 p-5">
<div>
<h2 className="text-base font-semibold text-zinc-900">新建公告</h2>
<p className="mt-1 text-xs leading-5 text-zinc-500">制度内容变化时请新建公告版本,不直接覆盖已签收内容。</p>
</div>
<div className="space-y-2">
<Label>公告类型</Label>
<Tabs value={mode} onValueChange={(value) => setMode(value as AnnouncementCategory)}>
<TabsList className="grid h-auto w-full grid-cols-2">
<TabsTrigger value="general">普通公告</TabsTrigger>
<TabsTrigger value="policy">制度签收</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="space-y-2">
<Label>标题</Label>
<Input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="例如:费用报销制度签收" />
</div>
<div className="space-y-2">
<Label>摘要</Label>
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="写清楚适用范围、入口和处理要求。" />
</div>
<div className="space-y-2">
<Label>链接</Label>
<Input value={linkUrl} onChange={(event) => setLinkUrl(event.target.value)} placeholder="https://..." />
</div>
{isPolicy ? (
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3.5">
<div className="space-y-2">
<Label>签收声明</Label>
<Textarea
className="min-h-[88px]"
value={acknowledgementStatement}
onChange={(event) => setAcknowledgementStatement(event.target.value)}
/>
</div>
))}
<div className="mt-3 space-y-2">
<Label>签收截止日</Label>
<Input type="date" value={acknowledgementDueDate} onChange={(event) => setAcknowledgementDueDate(event.target.value)} />
<p className="text-xs leading-5 text-zinc-500">按国内习惯,截止日当天 23:59 前均视为未逾期。</p>
</div>
</div>
) : null}
<div className="space-y-2">
<Label>优先级</Label>
<Input value={priority} onChange={(event) => setPriority(event.target.value)} inputMode="numeric" />
</div>
<Button type="button" disabled={createMutation.isPending} onClick={handleCreate}>
{createMutation.isPending ? "创建中..." : isPolicy ? "创建制度签收公告" : "创建公告"}
</Button>
</section>
<section className="admin-command-panel space-y-4 p-5">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-base font-semibold text-zinc-900">公告列表</h2>
<p className="mt-1 text-xs leading-5 text-zinc-500">管理员可查看制度签收统计;未签名单仅展示员工号和姓名。</p>
</div>
{listQuery.isFetching ? <span className="badge-neutral">刷新中</span> : null}
</div>
<div className="space-y-3">
{(listQuery.data?.items ?? []).map((item) => {
const summaryStats = item.acknowledgementSummary;
const pendingEmployees = item.pendingEmployees ?? [];
const pendingPreview = pendingEmployees.slice(0, 6);
return (
<article key={item.id} className="admin-tile-card space-y-3">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold text-zinc-900">{item.title}</h3>
<span className={item.requiresAcknowledgement ? "badge-warning" : "badge-neutral"}>
{acknowledgementModeLabel(item.category, item.requiresAcknowledgement)}
</span>
<span className={item.enabled ? "badge-success" : "badge-neutral"}>{item.enabled ? "启用" : "停用"}</span>
</div>
<p className="mt-2 text-sm leading-6 text-zinc-600">{item.summary}</p>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-zinc-500">
<span>优先级 {item.priority}</span>
<span>更新 {formatDisplayDateTime(item.updatedAt)}</span>
{item.acknowledgementDueAt ? <span>截止 {formatDisplayDate(item.acknowledgementDueAt)}</span> : null}
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => toggleMutation.mutate({ id: item.id, enabled: !item.enabled })}>
{item.enabled ? "停用" : "启用"}
</Button>
<Button type="button" variant="secondary" size="sm" onClick={() => archiveMutation.mutate(item.id)}>归档</Button>
</div>
</div>
{item.requiresAcknowledgement && summaryStats ? (
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3.5">
<div className="grid gap-2 text-sm md:grid-cols-4">
<div><span className="text-zinc-500">范围</span><div className="font-semibold text-zinc-900">{summaryStats.totalActiveEmployees} 人</div></div>
<div><span className="text-zinc-500">已签</span><div className="font-semibold text-emerald-700">{summaryStats.acknowledgedCount} 人</div></div>
<div><span className="text-zinc-500">待签</span><div className="font-semibold text-amber-700">{summaryStats.pendingCount} 人</div></div>
<div><span className="text-zinc-500">逾期</span><div className="font-semibold text-red-700">{summaryStats.overdueCount} 人</div></div>
</div>
<div className="mt-3 text-xs font-semibold text-zinc-500">{formatSummary(item)}</div>
{pendingPreview.length ? (
<div className="mt-2 flex flex-wrap gap-2">
{pendingPreview.map((employee) => (
<span key={employee.id} className={employee.status === "overdue" ? "badge-error" : "badge-warning"}>
{employee.employeeNo} · {employee.name}
</span>
))}
{pendingEmployees.length > pendingPreview.length ? (
<span className="badge-neutral">另 {pendingEmployees.length - pendingPreview.length} 人</span>
) : null}
</div>
) : (
<div className="mt-2 text-sm text-emerald-700">当前 active 员工均已签收。</div>
)}
</div>
) : null}
</article>
);
})}
{!listQuery.data?.items?.length ? <div className="text-sm text-zinc-500">暂无公告</div> : null}
</div>
</div>
</section>
</div>
</div>
);