实现制度签收轻量回执(05-28-oa-policy-acknowledgement-v1)
这个提交包含在:
@@ -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>
|
||||
);
|
||||
|
||||
在新工单中引用
屏蔽一个用户