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