WARCH2 增加企微审批历史只读查询
这个提交包含在:
@@ -114,6 +114,13 @@ export function MobilePortalPage() {
|
||||
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
|
||||
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
|
||||
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
|
||||
const canAccessWecomApprovalArchive = hasAnyPermission(permissions, [
|
||||
"leave.view_own",
|
||||
"attendance.view_own",
|
||||
"leave.view_all",
|
||||
"attendance.view_all",
|
||||
"*",
|
||||
]);
|
||||
const canCreateExpense = hasAnyPermission(permissions, ["expense.create"]);
|
||||
const currentMobileSource = `${location.pathname}${location.search}`;
|
||||
|
||||
@@ -351,11 +358,18 @@ export function MobilePortalPage() {
|
||||
<span className="badge-neutral">企业微信打卡</span>
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-zinc-600">
|
||||
日常打卡在企业微信完成;补卡、外出、加班和月度解释在 OA 处理。
|
||||
日常打卡、请假和补卡在企业微信完成;OA 保留已归档历史和考勤汇总。
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2">
|
||||
{canAccessWecomApprovalArchive ? (
|
||||
<Button asChild className="w-full" size="sm" type="button" variant="secondary">
|
||||
<Link to="/mobile/wecom-approval-archive">查看企微审批历史</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild className="w-full" size="sm" type="button" variant="secondary">
|
||||
<Link to="/attendance">查看考勤汇总</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
|
||||
<Link to="/attendance">查看考勤汇总</Link>
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-notifications">
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
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";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
|
||||
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: "仅未关闭 finding" },
|
||||
{ value: "resolved", label: "已关闭 finding" },
|
||||
{ value: "any", label: "有 finding" },
|
||||
];
|
||||
|
||||
function resolveError(error: unknown, fallback = "读取企微审批历史失败") {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
return error instanceof Error ? error.message : 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">finding {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 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">
|
||||
暂无已归档的企微审批历史。若公网 OA DB 还没有留存数据,验收记录为 PARTIAL_ARCHIVE_NEEDS_DATA。
|
||||
</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="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 text-xs text-zinc-500">单号前缀:{item.spNoPrefix || "-"}</div>
|
||||
</div>
|
||||
<StatusBadge status={item.normalizedStatus} />
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-zinc-600">{employeeLabel(item.applicant)} · {formatTime(item.statusObservedAt || item.completedAt || item.appliedAt)}</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-zinc-500">{sourceLabel(item.lastSource)}{item.openFindingCount ? ` · finding ${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 gap-4 lg:grid-cols-3">
|
||||
<div className="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 text-sm text-zinc-800">{employeeLabel(detail.applicant)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">单号前缀</div>
|
||||
<div className="mt-1 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="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 gap-1 px-3 py-2 text-sm md:grid-cols-3" key={field.id}>
|
||||
<span className="font-medium text-zinc-800">{field.fieldName || field.fieldKey}</span>
|
||||
<span className="text-zinc-500">{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 text-zinc-600">{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 text-zinc-600">{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">对账 finding</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="font-medium text-zinc-800">{item.findingType}</span>
|
||||
<span className="text-zinc-500">{item.status} · {item.severity} · {formatTime(item.detectedAt)}</span>
|
||||
</div>
|
||||
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无对账 finding。</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</SectionPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户