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

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

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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 { mapApiErrorMessage } from "../api/errorMessages";
import { fetchNotifications, markNotificationRead, type NotificationItem } from "../api/notifications";
import { fetchPortalHome } from "../api/portal";
import { isApiError } from "../api/types";
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) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
}
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 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}`;
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: canUseFinance,
queryKey: ["mobile", "portal", "home"],
queryFn: () => fetchPortalHome(),
});
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 paymentBatchEnabled = Boolean(financeSummary?.paymentBatchEnabled);
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_available",
to: "/mobile/leave?view=form",
}
: 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}
{canUseFinance ? (
<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">{paymentBatchEnabled ? "复核与付款" : "审批与台账"}</span>
</div>
{portalHomeQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
{portalHomeQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(portalHomeQuery.error)}
</p>
) : null}
{!portalHomeQuery.isLoading && !portalHomeQuery.isError && paymentBatchEnabled ? (
<div className="mobile-metric-grid text-sm">
<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">{financeSummary?.pendingBatchCount ?? 0}</div>
</div>
<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">
{(financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0)}
</div>
</div>
<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">可用</div>
</div>
</div>
) : null}
{!portalHomeQuery.isLoading && !portalHomeQuery.isError && !paymentBatchEnabled ? (
<div className="mobile-subcard text-sm text-zinc-600" data-testid="mobile-finance-disabled-state">
<div className="font-medium text-zinc-900">付款批次未启用</div>
<div className="mt-1 leading-5">可查看已流转到财务审批的报销单,待付款和异常批次暂不展示。</div>
</div>
) : null}
</section>
) : null}
<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>
<section className="mobile-card mobile-page-stack" data-testid="mobile-attendance-summary-card">
<div className="mobile-section-header">
<div className="mobile-section-title">考勤</div>
<span className="badge-neutral">企业微信打卡</span>
</div>
<div className="text-sm leading-6 text-zinc-600">
日常打卡、请假和补卡在企业微信完成;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>
</section>
<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>
);
}