文件
hyb-oa/client/src/pages/MobilePortalPage.tsx
T
wangsli 5d8cdf12b4
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
fix: 隔离门户待办身份并取消旧请求(09-11-reimbursement-payment-handoffs)
2026-09-11 20:21:46 +08:00

399 行
16 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchNotifications, markNotificationRead, type NotificationItem } from "../api/notifications";
import { fetchPortalHome } from "../api/portal";
import { fetchWorkflowTasks, fetchWorkflowTaskSummary } from "../api/workflow";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasBusinessApproverRole, hasFinanceRole } from "../auth/permissions";
import { businessEntityLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
import { resolveMobileNotificationTargetLink } from "../lib/notificationLinks";
import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { MobileIcon } from "../components/mobile/MobileIcon";
import { Button } from "../components/ui/button";
import {
MOBILE_CAPTURE_PERMISSIONS,
MOBILE_ASSET_SCAN_PERMISSIONS,
MOBILE_HOME_ASSET_SCAN_PERMISSIONS,
MOBILE_LEAVE_PERMISSIONS,
MOBILE_PURCHASE_PERMISSIONS,
MOBILE_REIMBURSEMENT_PERMISSIONS,
} from "../features/mobile/navigation";
type MobileAction = {
key: string;
label: string;
meta: string;
icon: string;
to: string;
tone?: "primary" | "warning";
};
function resolveErrorMessage(error: unknown) {
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function formatDateTime(value: string | null) {
if (!value) {
return "-";
}
try {
return new Date(value).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
} catch {
return value;
}
}
function notificationTypeLabel(type: string) {
if (type.startsWith("workflow.")) return "审批事件";
if (type.startsWith("reimbursement.")) return "报销事件";
if (type.startsWith("leave.")) return "请假事件";
if (type.startsWith("purchase_request.")) return "采购事件";
if (type.startsWith("travel_request.")) return "差旅事件";
if (type.startsWith("asset.")) return "资产事件";
return "系统事件";
}
function ActionTile({ action }: { action: MobileAction }) {
const isPrimary = action.tone === "primary";
const isWarning = action.tone === "warning";
return (
<Link
to={action.to}
className={
isPrimary
? "mobile-icon-action mobile-icon-action--primary"
: isWarning
? "mobile-icon-action mobile-icon-action--warning"
: "mobile-icon-action"
}
data-testid={`mobile-action-${action.key}`}
title={`${action.label}:${action.meta}`}
aria-label={`${action.label},${action.meta}`}
>
<MobileIcon
className="mobile-icon-action__icon"
data-testid={`mobile-action-${action.key}-icon`}
name={action.icon}
/>
<span className="mobile-icon-action__label" data-testid={`mobile-action-${action.key}-label`}>
{action.label}
</span>
<span className="sr-only">{action.meta}</span>
</Link>
);
}
export function MobilePortalPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const { me, permissions } = useAuth();
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
const canUseFinance = hasFinanceRole(me?.roleCodes, permissions);
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
const canAccessAssetScan = hasAnyPermission(permissions, [...MOBILE_ASSET_SCAN_PERMISSIONS]);
const canShowAssetScanOnHome = hasAnyPermission(permissions, [...MOBILE_HOME_ASSET_SCAN_PERMISSIONS]);
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
const canCreateExpense = hasAnyPermission(permissions, ["expense.create"]);
const currentMobileSource = `${location.pathname}${location.search}`;
const pendingCountQuery = useQuery({
enabled: canAccessApprovals,
queryKey: ["mobile", "workflow", "pending-items"],
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 3 }),
});
const workflowSummaryQuery = useQuery({
enabled: canAccessApprovals,
queryKey: ["mobile", "workflow", "summary"],
queryFn: () => fetchWorkflowTaskSummary(),
});
const notificationsQuery = useQuery({
queryKey: ["mobile", "portal", "notifications"],
queryFn: () => fetchNotifications({ page: 1, pageSize: 6 }),
});
const portalHomeQuery = useQuery({
enabled: Boolean(me?.id),
queryKey: ["mobile", "portal", "home", me?.id],
queryFn: ({ signal }) => fetchPortalHome(signal),
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
});
useEffect(() => {
if (location.hash !== "#finance" || !canUseFinance) {
return;
}
const frameId = window.requestAnimationFrame(() => {
const financeSection = document.getElementById("finance");
financeSection?.scrollIntoView({ block: "start", behavior: "smooth" });
financeSection?.focus({ preventScroll: true });
});
return () => window.cancelAnimationFrame(frameId);
}, [canUseFinance, location.hash, portalHomeQuery.isError, portalHomeQuery.isLoading]);
const markHomeNotificationReadMutation = useMutation({
mutationFn: (notificationId: string) => markNotificationRead(notificationId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mobile", "portal", "notifications"] }),
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
]);
},
});
const pendingApprovalCount = canAccessApprovals
? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0
: 0;
const pendingTasks = pendingCountQuery.data?.items ?? [];
const urgentPendingTask = pendingTasks[0] ?? null;
const notifications = notificationsQuery.data?.items ?? [];
const homeNotifications = notifications
.filter((item) => !(canAccessApprovals && item.type.startsWith("workflow.task_")))
.slice(0, 3);
const financeSummary = portalHomeQuery.data?.financeSummary ?? null;
const businessHandoffs = portalHomeQuery.data?.businessHandoffs;
const paymentBatchEnabled = Boolean(financeSummary?.paymentBatchEnabled);
const financePendingCount = financeSummary?.pendingBatchCount ?? 0;
const financeExceptionCount = (businessHandoffs ? 0 : financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0);
const shouldShowFinanceCard = canUseFinance &&
!portalHomeQuery.isLoading &&
!portalHomeQuery.isError &&
(businessHandoffs ? financeExceptionCount > 0 : paymentBatchEnabled || financePendingCount > 0 || financeExceptionCount > 0);
const financeCardBadge = paymentBatchEnabled ? "复核与付款" : financePendingCount > 0 ? "待付款" : "异常与复核";
async function openHomeNotification(item: NotificationItem) {
const targetLink = resolveMobileNotificationTargetLink(item);
if (!targetLink) {
navigate("/mobile/notifications", { state: { from: currentMobileSource } });
return;
}
try {
if (!item.readAt) {
await markHomeNotificationReadMutation.mutateAsync(item.id);
}
navigate(targetLink, { state: { from: currentMobileSource } });
} catch (error) {
toast.error(resolveErrorMessage(error));
}
}
const actions = [
canCreateExpense && canAccessReimbursements
? {
key: "new-reimbursement",
label: "新建报销",
meta: "填写明细",
icon: "request_quote",
to: "/mobile/reimbursements?intent=create",
tone: "primary",
}
: null,
canAccessCapture
? {
key: "capture",
label: "上传票据",
meta: "上传 PDF",
icon: "receipt_long",
to: "/mobile/capture",
}
: null,
canAccessLeave
? {
key: "leave",
label: "请假记录",
meta: "企微主办",
icon: "event_note",
to: "/mobile/leave",
}
: null,
canAccessPurchase
? {
key: "purchase",
label: "采购",
meta: "查看申请",
icon: "shopping_cart",
to: "/mobile/purchase-requests",
}
: null,
canAccessAssetScan && canShowAssetScanOnHome
? {
key: "asset-scan",
label: "资产扫码",
meta: "扫码盘点",
icon: "qr_code_scanner",
to: "/mobile/assets/scan",
}
: null,
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
return (
<PageScaffold className="mobile-page-stack">
<PageHeader
title="今日待处理"
meta={<span className="badge-neutral">移动工作台</span>}
/>
{canAccessApprovals && pendingApprovalCount > 0 ? (
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-pending-summary">
<div className="mobile-section-header mobile-section-header--top">
<div className="min-w-0">
<div className="mobile-section-title">待我审批</div>
<div className="mobile-list-card__subtitle mt-1">今日待处理</div>
</div>
<div className="text-right">
<div className="text-3xl font-semibold leading-none text-zinc-900" data-testid="mobile-pending-summary-count">
{pendingApprovalCount}
</div>
{workflowSummaryQuery.data?.overdueTotal ? (
<div className="mt-1 text-xs font-medium text-amber-700">超时 {workflowSummaryQuery.data.overdueTotal}</div>
) : null}
</div>
</div>
{urgentPendingTask ? (
<div className="mobile-page-stack">
{(() => {
const task = urgentPendingTask;
const entityType = task.workflowInstance?.entityType;
const reference = compactBusinessReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId);
return (
<Link
key={task.id}
to={`/mobile/approvals/${encodeURIComponent(task.id)}`}
state={{ from: currentMobileSource }}
className="mobile-list-card block text-sm"
data-testid="mobile-pending-summary-primary"
>
<div className="mobile-list-card__header">
<div className="mobile-list-card__title">
{reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })}
</div>
</div>
<div className="mobile-list-card__subtitle mt-1">
{joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])}
</div>
</Link>
);
})()}
</div>
) : null}
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
<Link to="/mobile/approvals">查看全部审批</Link>
</Button>
</section>
) : null}
{portalHomeQuery.isError ? <section className="mobile-card space-y-3" role="alert"><p>业务待办加载失败,请重试。</p><Button onClick={() => void portalHomeQuery.refetch()} type="button" variant="secondary">重试加载待办</Button></section> : null}
{!portalHomeQuery.isError && businessHandoffs && businessHandoffs.total > 0 ? (
<section className="mobile-card mobile-page-stack" data-testid="mobile-business-handoffs">
<div className="mobile-section-header"><h2 className="mobile-section-title">业务待办</h2><span>{businessHandoffs.total} 项</span></div>
{businessHandoffs.items.map((item) => (
<Link className="mobile-list-card block" key={item.key} to={item.mobileHref}>
<div className="mobile-list-card__title">{item.title}{item.reference ? ` · ${item.reference}` : ""}</div>
<p className="mobile-list-card__subtitle mt-1">{item.nextStep}</p>
<p className="mt-1 text-xs text-zinc-500">{item.ownerLabel} · 查看处理</p>
</Link>
))}
</section>
) : null}
{shouldShowFinanceCard ? (
<section id="finance" tabIndex={-1} className="mobile-card mobile-page-stack" data-testid="mobile-finance-summary">
<div className="mobile-section-header">
<div className="mobile-section-title">财务</div>
<span className="badge-neutral">{financeCardBadge}</span>
</div>
<div className="mobile-metric-grid text-sm">
{!businessHandoffs ? <div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">待付款</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{financePendingCount}</div>
</div> : null}
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">异常</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{financeExceptionCount}</div>
</div>
</div>
{paymentBatchEnabled ? (
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
<Link to="/mobile/payment-batches">进入付款</Link>
</Button>
) : null}
</section>
) : null}
{actions.length ? (
<section className="mobile-card mobile-page-stack" data-testid="mobile-today-focus">
<div className="mobile-section-header">
<div className="mobile-section-title">常用动作</div>
</div>
<div className="mobile-icon-grid" data-testid="mobile-action-grid">
{actions.map((action) => (
<ActionTile key={action.key} action={action} />
))}
</div>
</section>
) : null}
<section className="mobile-card mobile-page-stack" data-testid="mobile-notifications">
<div className="mobile-section-header">
<div className="mobile-section-title">通知</div>
<Button asChild size="sm" type="button" variant="secondary">
<Link to="/mobile/notifications">全部</Link>
</Button>
</div>
{notificationsQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
{notificationsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(notificationsQuery.error)}
</p>
) : null}
{!notificationsQuery.isLoading && !homeNotifications.length ? (
<p className="text-sm text-zinc-500">暂无通知。</p>
) : null}
<div className="mobile-page-stack">
{homeNotifications.map((item) => (
<button
key={item.id}
type="button"
className="mobile-list-card w-full text-left text-sm"
onClick={() => void openHomeNotification(item)}
data-testid="mobile-home-notification-card"
>
<div className="mobile-list-card__header">
<div className="min-w-0">
<div className="text-xs font-medium text-primary">{notificationTypeLabel(item.type)}</div>
<div className="mobile-list-card__title mt-0.5">{item.title}</div>
</div>
{!item.readAt ? <span className="badge-warning mobile-status-slot">新</span> : null}
</div>
<div className="mobile-list-card__subtitle mt-1">{formatDateTime(item.createdAt)}</div>
</button>
))}
</div>
</section>
</PageScaffold>
);
}