汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
233 行
12 KiB
TypeScript
233 行
12 KiB
TypeScript
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
|
import { useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
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 { 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();
|
|
const [title, setTitle] = useState("");
|
|
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),
|
|
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: (error) => toast.error(resolveErrorMessage(error, "公告创建失败,请检查填写内容")),
|
|
});
|
|
|
|
const archiveMutation = useMutation({
|
|
mutationFn: archiveAnnouncement,
|
|
onSuccess: async () => {
|
|
await refresh();
|
|
toast.success("公告已归档");
|
|
},
|
|
onError: (error) => toast.error(resolveErrorMessage(error, "公告归档失败,请刷新后重试")),
|
|
});
|
|
|
|
const toggleMutation = useMutation({
|
|
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => updateAnnouncement(id, { enabled }),
|
|
onSuccess: async () => {
|
|
await refresh();
|
|
toast.success("公告状态已更新");
|
|
},
|
|
onError: (error) => toast.error(resolveErrorMessage(error, "公告状态更新失败,请刷新后重试")),
|
|
});
|
|
|
|
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="发布普通公告,或创建需要全员显式确认的制度签收公告。" />
|
|
<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>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|