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

341 行
17 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
fetchWecomApprovalArchive,
fetchWecomApprovalArchiveDetail,
type WecomApprovalArchiveFinding,
type WecomApprovalArchiveItem,
type WecomApprovalArchiveScope,
type WecomApprovalArchiveStatus,
type WecomApprovalArchiveType,
} from "../api/wecomApprovalArchive";
import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Label } from "../components/ui/label";
const TYPE_OPTIONS: Array<{ value: "" | WecomApprovalArchiveType; label: string }> = [
{ value: "", label: "全部类型" },
{ value: "leave", label: "请假" },
{ value: "missed_punch", label: "补卡" },
];
const STATUS_OPTIONS: Array<{ value: "" | WecomApprovalArchiveStatus; label: string }> = [
{ value: "", label: "全部状态" },
{ value: "approved", label: "已通过" },
{ value: "rejected", label: "已驳回" },
{ value: "revoked", label: "已撤销" },
{ value: "terminated", label: "已终止" },
{ value: "pending", label: "处理中" },
{ value: "unknown", label: "未知" },
];
const FINDING_OPTIONS: Array<{ value: "" | WecomApprovalArchiveFinding; label: string }> = [
{ value: "", label: "全部对账" },
{ value: "open", label: "仅未关闭问题" },
{ value: "resolved", label: "已关闭问题" },
{ value: "any", label: "有对账问题" },
];
function resolveError(error: unknown, fallback = "读取企微审批历史失败") {
return resolveUserErrorMessage(error, fallback);
}
function typeLabel(value: string) {
if (value === "leave") return "请假";
if (value === "missed_punch") return "补卡";
return value || "未知";
}
function sourceLabel(value: string | null) {
if (value === "callback") return "回调";
if (value === "compensation_pull") return "补偿拉取";
return value || "-";
}
function actionLabel(value: string) {
if (value === "approve") return "同意";
if (value === "reject") return "驳回";
if (value === "revoke") return "撤销";
if (value === "terminate") return "终止";
if (value === "pending") return "状态记录";
return value || "状态记录";
}
function roleLabel(value: string) {
if (value === "applicant") return "申请人";
if (value === "approver") return "审批人";
if (value === "notifyer") return "抄送人";
return value || "-";
}
function formatTime(value: string | null) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
function employeeLabel(employee: WecomApprovalArchiveItem["applicant"]) {
if (!employee) return "未映射";
return employee.employeeNo ? `${employee.name}(${employee.employeeNo})` : employee.name;
}
function canRequestAll(roleCodes: string[], permissions: string[]) {
return roleCodes.includes("hr") || roleCodes.includes("admin") || permissions.includes("*");
}
function ArchiveRow({
item,
active,
onSelect,
}: {
item: WecomApprovalArchiveItem;
active: boolean;
onSelect: () => void;
}) {
return (
<>
<tr className={active ? "bg-primary/5" : ""}>
<td className="px-3 py-3 align-top">
<div className="font-medium text-zinc-900">{typeLabel(item.templateType)}</div>
<div className="mt-1 text-xs text-zinc-500">{item.spName || "OA-POC 归档记录"}</div>
<div className="mt-1 text-xs text-zinc-500">单号前缀:{item.spNoPrefix || "-"}</div>
</td>
<td className="px-3 py-3 align-top text-sm text-zinc-700">{employeeLabel(item.applicant)}</td>
<td className="px-3 py-3 align-top"><StatusBadge status={item.normalizedStatus} /></td>
<td className="px-3 py-3 align-top text-sm text-zinc-600">{formatTime(item.statusObservedAt || item.completedAt || item.appliedAt)}</td>
<td className="px-3 py-3 align-top text-sm text-zinc-600">
<div>{sourceLabel(item.lastSource)}</div>
{item.openFindingCount ? <div className="mt-1 text-xs text-amber-700">对账问题 {item.openFindingCount} 项</div> : null}
</td>
<td className="px-3 py-3 align-top text-right">
<Button type="button" variant="secondary" onClick={onSelect}>查看历史</Button>
</td>
</tr>
</>
);
}
export function WecomApprovalArchivePage() {
const { me, permissions } = useAuth();
const [scope, setScope] = useState<WecomApprovalArchiveScope>("self");
const [type, setType] = useState<"" | WecomApprovalArchiveType>("");
const [status, setStatus] = useState<"" | WecomApprovalArchiveStatus>("");
const [finding, setFinding] = useState<"" | WecomApprovalArchiveFinding>("");
const [selectedId, setSelectedId] = useState("");
const allAllowed = canRequestAll(me?.roleCodes ?? [], permissions);
const query = useMemo(() => ({ scope, type, status, finding, pageSize: 50 }), [scope, type, status, finding]);
const listQuery = useQuery({
queryKey: ["wecom-approval-archive", query],
queryFn: () => fetchWecomApprovalArchive(query),
});
const detailQuery = useQuery({
enabled: Boolean(selectedId),
queryKey: ["wecom-approval-archive", "detail", selectedId],
queryFn: () => fetchWecomApprovalArchiveDetail(selectedId),
});
const items = listQuery.data?.items ?? [];
const detail = detailQuery.data?.item ?? null;
return (
<div className="page-shell min-w-0 max-w-full space-y-6" data-testid="wecom-approval-archive-page">
<PageHeader
eyebrow="企微审批历史"
title="企微审批历史"
subtitle="企业微信主办,OA 自建留存;仅已归档记录。这里用于历史查看、审计和补偿对账,不提供审批操作。"
meta={
<>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">企业微信主办,OA 自建留存</span>
<span className="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-700">仅已归档记录</span>
</>
}
/>
<SectionPanel
title="历史筛选"
description="范围只覆盖已入库的请假、补卡记录;完整单号和企微 userid 仅保存在数据库内。"
>
<div className="grid gap-3 md:grid-cols-4">
<div className="space-y-1.5">
<Label htmlFor="wecom-archive-scope">查看范围</Label>
<select
id="wecom-archive-scope"
className="input-std"
value={scope}
onChange={(event) => {
setScope(event.target.value as WecomApprovalArchiveScope);
setSelectedId("");
}}
>
<option value="self">与我相关</option>
<option value="team">直属团队</option>
{allAllowed ? <option value="all">HR/管理员全量</option> : null}
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="wecom-archive-type">类型</Label>
<select id="wecom-archive-type" className="input-std" value={type} onChange={(event) => setType(event.target.value as "" | WecomApprovalArchiveType)}>
{TYPE_OPTIONS.map((option) => <option key={option.value || "all"} value={option.value}>{option.label}</option>)}
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="wecom-archive-status">状态</Label>
<select id="wecom-archive-status" className="input-std" value={status} onChange={(event) => setStatus(event.target.value as "" | WecomApprovalArchiveStatus)}>
{STATUS_OPTIONS.map((option) => <option key={option.value || "all"} value={option.value}>{option.label}</option>)}
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="wecom-archive-finding">对账</Label>
<select id="wecom-archive-finding" className="input-std" value={finding} onChange={(event) => setFinding(event.target.value as "" | WecomApprovalArchiveFinding)}>
{FINDING_OPTIONS.map((option) => <option key={option.value || "all"} value={option.value}>{option.label}</option>)}
</select>
</div>
</div>
</SectionPanel>
<SectionPanel
title="归档记录"
description={`当前返回 ${items.length} 条,服务端总数 ${listQuery.data?.total ?? 0} 条。`}
>
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
{listQuery.isError ? <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{resolveError(listQuery.error)}</div> : null}
{!listQuery.isLoading && !listQuery.isError && !items.length ? (
<div className="rounded-md border border-dashed border-slate-300 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500">
暂无已归档的企微审批历史;如数据尚未同步,可稍后重试或联系管理员核对归档状态。
</div>
) : null}
{!listQuery.isLoading && !listQuery.isError && items.length ? (
<>
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full divide-y divide-slate-200 text-left">
<thead className="bg-slate-50 text-xs font-semibold uppercase text-slate-500">
<tr>
<th className="px-3 py-2">类型/单号</th>
<th className="px-3 py-2">申请人</th>
<th className="px-3 py-2">状态</th>
<th className="px-3 py-2">状态时间</th>
<th className="px-3 py-2">来源/对账</th>
<th className="px-3 py-2 text-right">详情</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{items.map((item) => (
<ArchiveRow key={item.id} item={item} active={selectedId === item.id} onSelect={() => setSelectedId(item.id)} />
))}
</tbody>
</table>
</div>
<div className="divide-y divide-slate-100 md:hidden">
{items.map((item) => (
<article className="min-w-0 py-3" key={item.id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-zinc-900">{typeLabel(item.templateType)}</div>
<div className="mt-1 break-all text-xs text-zinc-500">单号前缀:{item.spNoPrefix || "-"}</div>
</div>
<StatusBadge status={item.normalizedStatus} />
</div>
<div className="mt-2 break-words text-sm text-zinc-600 [overflow-wrap:anywhere]">{employeeLabel(item.applicant)} · {formatTime(item.statusObservedAt || item.completedAt || item.appliedAt)}</div>
<div className="mt-2 flex items-center justify-between gap-3">
<span className="min-w-0 break-words text-xs text-zinc-500 [overflow-wrap:anywhere]">{sourceLabel(item.lastSource)}{item.openFindingCount ? ` · 对账问题 ${item.openFindingCount}` : ""}</span>
<Button type="button" variant="secondary" onClick={() => setSelectedId(item.id)}>查看历史</Button>
</div>
</article>
))}
</div>
</>
) : null}
</SectionPanel>
<SectionPanel
title="历史详情"
description="详情只展示字段名称、字段类型、动作来源和脱敏人员摘要;不展示表单原文、评论、附件或完整企微标识。"
>
{!selectedId ? <div className="text-sm text-zinc-500">请选择一条归档记录查看详情。</div> : null}
{selectedId && detailQuery.isLoading ? <TableSkeleton columns={1} rows={3} /> : null}
{selectedId && detailQuery.isError ? <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{resolveError(detailQuery.error)}</div> : null}
{detail ? (
<div className="grid min-w-0 max-w-full gap-4 lg:grid-cols-3">
<div className="min-w-0 space-y-3 lg:col-span-1">
<div>
<div className="text-xs text-zinc-500">状态</div>
<div className="mt-1"><StatusBadge status={detail.normalizedStatus} /></div>
</div>
<div>
<div className="text-xs text-zinc-500">申请人</div>
<div className="mt-1 break-words text-sm text-zinc-800 [overflow-wrap:anywhere]">{employeeLabel(detail.applicant)}</div>
</div>
<div>
<div className="text-xs text-zinc-500">单号前缀</div>
<div className="mt-1 break-all text-sm text-zinc-800">{detail.spNoPrefix || "-"}</div>
</div>
{detail.singleTapApproveRisk ? <div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">企微同意可能单击即通过,OA 仅留存动作来源和历史审计。</div> : null}
</div>
<div className="min-w-0 space-y-4 lg:col-span-2">
<div>
<h4 className="text-sm font-semibold text-zinc-900">字段摘要</h4>
<div className="mt-2 divide-y divide-slate-100 rounded-md border border-slate-200">
{detail.fields.length ? detail.fields.map((field) => (
<div className="grid min-w-0 gap-1 px-3 py-2 text-sm md:grid-cols-3" key={field.id}>
<span className="min-w-0 break-words font-medium text-zinc-800 [overflow-wrap:anywhere]">{field.fieldName || field.fieldKey}</span>
<span className="min-w-0 break-words text-zinc-500 [overflow-wrap:anywhere]">{field.fieldType || "-"}</span>
<span className="text-zinc-500">{field.sensitiveLevel === "restricted" ? "受限字段" : "普通字段"}</span>
</div>
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无字段摘要。</div>}
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-zinc-900">参与人与动作</h4>
<div className="mt-2 grid gap-3 md:grid-cols-2">
<div className="rounded-md border border-slate-200">
{detail.participants.length ? detail.participants.map((item) => (
<div className="border-b border-slate-100 px-3 py-2 text-sm last:border-b-0" key={item.id}>
<span className="font-medium text-zinc-800">{roleLabel(item.role)}</span>
<span className="ml-2 break-words text-zinc-600 [overflow-wrap:anywhere]">{employeeLabel(item.employee)}</span>
</div>
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无映射参与人。</div>}
</div>
<div className="rounded-md border border-slate-200">
{detail.actions.length ? detail.actions.map((item) => (
<div className="border-b border-slate-100 px-3 py-2 text-sm last:border-b-0" key={item.id}>
<span className="font-medium text-zinc-800">{actionLabel(item.actionType)}</span>
<span className="ml-2 break-words text-zinc-600 [overflow-wrap:anywhere]">{employeeLabel(item.operator)}</span>
<div className="mt-1 text-xs text-zinc-500">{formatTime(item.actionTime)} · {sourceLabel(item.source)}</div>
</div>
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无动作历史。</div>}
</div>
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-zinc-900">对账问题</h4>
<div className="mt-2 divide-y divide-slate-100 rounded-md border border-slate-200">
{detail.findings.length ? detail.findings.map((item) => (
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2 text-sm" key={item.id}>
<span className="min-w-0 break-words font-medium text-zinc-800 [overflow-wrap:anywhere]">{item.findingType}</span>
<span className="min-w-0 break-words text-zinc-500 [overflow-wrap:anywhere]">{item.status} · {item.severity} · {formatTime(item.detectedAt)}</span>
</div>
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无对账问题。</div>}
</div>
</div>
</div>
</div>
) : null}
</SectionPanel>
</div>
);
}