WARCH2 增加企微审批历史只读查询
这个提交包含在:
@@ -0,0 +1,112 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type WecomApprovalArchiveScope = "self" | "team" | "all";
|
||||
export type WecomApprovalArchiveType = "leave" | "missed_punch";
|
||||
export type WecomApprovalArchiveStatus = "pending" | "approved" | "rejected" | "revoked" | "terminated" | "changed" | "unknown";
|
||||
export type WecomApprovalArchiveFinding = "open" | "resolved" | "any";
|
||||
|
||||
export type WecomApprovalArchiveEmployee = {
|
||||
id: string;
|
||||
employeeNo: string | null;
|
||||
name: string;
|
||||
} | null;
|
||||
|
||||
export type WecomApprovalArchiveItem = {
|
||||
id: string;
|
||||
spNoPrefix: string | null;
|
||||
templateIdPrefix: string | null;
|
||||
templateType: string;
|
||||
spName: string | null;
|
||||
normalizedStatus: string;
|
||||
applicant: WecomApprovalArchiveEmployee;
|
||||
appliedAt: string | null;
|
||||
submittedAt: string | null;
|
||||
completedAt: string | null;
|
||||
statusObservedAt: string | null;
|
||||
lastSource: string | null;
|
||||
lastCallbackAt: string | null;
|
||||
lastCompensationAt: string | null;
|
||||
readabilityStatus: string | null;
|
||||
singleTapApproveRisk: boolean;
|
||||
templateAdjustmentRequired: boolean;
|
||||
participantCount: number;
|
||||
actionCount: number;
|
||||
fieldCount: number;
|
||||
findingCount: number;
|
||||
openFindingCount: number;
|
||||
};
|
||||
|
||||
export type WecomApprovalArchiveDetail = WecomApprovalArchiveItem & {
|
||||
participants: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
nodeIndex: number;
|
||||
employee: WecomApprovalArchiveEmployee;
|
||||
isCurrent: boolean;
|
||||
mappingStatus: string;
|
||||
}>;
|
||||
actions: Array<{
|
||||
id: string;
|
||||
actionType: string;
|
||||
operator: WecomApprovalArchiveEmployee;
|
||||
actionTime: string | null;
|
||||
nodeIndex: number;
|
||||
source: string;
|
||||
}>;
|
||||
fields: Array<{
|
||||
id: string;
|
||||
fieldKey: string;
|
||||
fieldIndex: number;
|
||||
fieldName: string | null;
|
||||
fieldType: string | null;
|
||||
sensitiveLevel: string;
|
||||
displayAllowed: boolean;
|
||||
}>;
|
||||
findings: Array<{
|
||||
id: string;
|
||||
findingType: string;
|
||||
status: string;
|
||||
severity: string;
|
||||
source: string;
|
||||
detectedAt: string | null;
|
||||
resolvedAt: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type WecomApprovalArchiveListQuery = {
|
||||
scope?: WecomApprovalArchiveScope;
|
||||
type?: "" | WecomApprovalArchiveType;
|
||||
status?: "" | WecomApprovalArchiveStatus;
|
||||
finding?: "" | WecomApprovalArchiveFinding;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export type WecomApprovalArchiveListResponse = {
|
||||
scope: WecomApprovalArchiveScope;
|
||||
requestedScope: WecomApprovalArchiveScope;
|
||||
canViewAll: boolean;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
status: "OK" | "PARTIAL_ARCHIVE_NEEDS_DATA";
|
||||
items: WecomApprovalArchiveItem[];
|
||||
};
|
||||
|
||||
function buildQuery(query: WecomApprovalArchiveListQuery = {}) {
|
||||
const search = new URLSearchParams();
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === "") return;
|
||||
search.set(key, String(value));
|
||||
});
|
||||
const value = search.toString();
|
||||
return value ? `?${value}` : "";
|
||||
}
|
||||
|
||||
export function fetchWecomApprovalArchive(query: WecomApprovalArchiveListQuery = {}) {
|
||||
return apiRequestData<WecomApprovalArchiveListResponse>(`/api/wecom/approval-archive${buildQuery(query)}`);
|
||||
}
|
||||
|
||||
export function fetchWecomApprovalArchiveDetail(id: string) {
|
||||
return apiRequestData<{ item: WecomApprovalArchiveDetail }>(`/api/wecom/approval-archive/${encodeURIComponent(id)}`);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"];
|
||||
export const ADMIN_NAV_ORDER = ["/system/workspace", "/people", "/organization", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"];
|
||||
export const BACKUP_NAV_ORDER = ["/attendance", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"];
|
||||
export const BACKUP_NAV_ORDER = ["/attendance", "/wecom-approval-archive", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"];
|
||||
|
||||
const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||
{ path: "/portal", title: "工作台", group: "工作台" },
|
||||
{ path: "/announcements", title: "公告", group: "主导航" },
|
||||
{ path: "/team", title: "团队", group: "主导航" },
|
||||
{ path: "/attendance", title: "考勤", group: "备用模块" },
|
||||
{ path: "/wecom-approval-archive", title: "企微审批历史", group: "备用模块" },
|
||||
{ path: "/projects", title: "项目", group: "主导航" },
|
||||
{ path: "/contracts-registry", title: "合同台账", group: "备用模块" },
|
||||
{ path: "/purchase-requests", title: "采购申请", group: "备用模块" },
|
||||
@@ -26,6 +27,7 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||
{ path: "/workflow/tasks", title: "审批待办", group: "主导航" },
|
||||
{ path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" },
|
||||
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
|
||||
{ path: "/mobile/wecom-approval-archive", title: "移动企微审批历史", group: "移动工作台" },
|
||||
{ path: "/mobile/capture", title: "移动 PDF 发票上传", group: "移动工作台" },
|
||||
{ path: "/mobile/leave", title: "移动请假", group: "移动工作台" },
|
||||
{ path: "/mobile/me", title: "移动我的", group: "移动工作台" },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
ReimbursementsPage,
|
||||
TravelRequestsPage,
|
||||
UserManagementPage,
|
||||
WecomApprovalArchivePage,
|
||||
WeeklyReportsPage,
|
||||
WorkspaceSetupPage,
|
||||
WorkflowDefinitionsPage,
|
||||
@@ -155,6 +156,15 @@ export function AppRouter() {
|
||||
element={withRouteFallback(<AttendancePage />)}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="wecom-approval-archive"
|
||||
element={
|
||||
<RequirePermission anyOf={["leave.view_own", "attendance.view_own", "leave.view_all", "attendance.view_all", "*"]}>
|
||||
{withRouteFallback(<WecomApprovalArchivePage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="reimbursements"
|
||||
element={
|
||||
@@ -311,6 +321,15 @@ export function AppRouter() {
|
||||
|
||||
<Route path="mobile/notifications" element={withRouteFallback(<MobileNotificationsPage />)} />
|
||||
|
||||
<Route
|
||||
path="mobile/wecom-approval-archive"
|
||||
element={
|
||||
<RequirePermission anyOf={["leave.view_own", "attendance.view_own", "leave.view_all", "attendance.view_all", "*"]}>
|
||||
{withRouteFallback(<WecomApprovalArchivePage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="mobile/capture"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { WecomApprovalArchivePage as default } from "../../pages/WecomApprovalArchivePage";
|
||||
@@ -39,6 +39,7 @@ export const OrganizationPage = lazy(() => import("./OrganizationPage"));
|
||||
export const EmployeeProfilePage = lazy(() => import("./EmployeeProfilePage"));
|
||||
export const TeamPresencePage = lazy(() => import("./TeamPresencePage"));
|
||||
export const WeeklyReportsPage = lazy(() => import("./WeeklyReportsPage"));
|
||||
export const WecomApprovalArchivePage = lazy(() => import("./WecomApprovalArchivePage"));
|
||||
export const TodosPage = lazy(() => import("./TodosPage"));
|
||||
export const WorkspaceSetupPage = lazy(() => import("./WorkspaceSetupPage"));
|
||||
export const SystemSettingsPage = lazy(() => import("./SystemSettingsPage"));
|
||||
|
||||
在新工单中引用
屏蔽一个用户