341 行
17 KiB
TypeScript
341 行
17 KiB
TypeScript
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>
|
||
);
|
||
}
|