R51 移动端增强:入口页、底部导航与审批批量处理
这个提交包含在:
@@ -0,0 +1,282 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchNotifications } from "../api/notifications";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { fetchMyPresence, upsertMyPresence, type PresenceItem } from "../api/presence";
|
||||
import { fetchWorkflowTasks } from "../api/workflow";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
MOBILE_APPROVAL_PERMISSIONS,
|
||||
MOBILE_ASSET_SCAN_PERMISSIONS,
|
||||
MOBILE_CAPTURE_PERMISSIONS,
|
||||
MOBILE_LEAVE_PERMISSIONS,
|
||||
MOBILE_REIMBURSEMENT_PERMISSIONS,
|
||||
} from "../features/mobile/navigation";
|
||||
import { buildMobileApprovalsPath } from "../pwa";
|
||||
|
||||
const QUICK_PRESENCE_OPTIONS = [
|
||||
{ label: "在岗", value: "office" as const },
|
||||
{ label: "居家", value: "remote" as const },
|
||||
{ label: "外出", value: "out" as const },
|
||||
];
|
||||
|
||||
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) {
|
||||
try {
|
||||
return new Date(value).toLocaleString("zh-CN");
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function presenceStatusLabel(item: PresenceItem | null) {
|
||||
if (!item) {
|
||||
return "未更新";
|
||||
}
|
||||
|
||||
switch (item.status) {
|
||||
case "office":
|
||||
return "在岗";
|
||||
case "remote":
|
||||
return "居家";
|
||||
case "out":
|
||||
return "外出";
|
||||
case "travel":
|
||||
return "出差";
|
||||
case "leave":
|
||||
return "请假";
|
||||
default:
|
||||
return item.status;
|
||||
}
|
||||
}
|
||||
|
||||
function canQuickUpdatePresence(item: PresenceItem | null) {
|
||||
return !(item?.status === "leave" && item.source === "leave_sync");
|
||||
}
|
||||
|
||||
function resolveNotificationTargetPath(
|
||||
item: { targetType: string | null; targetId: string | null },
|
||||
canAccessApprovals: boolean,
|
||||
) {
|
||||
if (!item.targetType || !item.targetId) {
|
||||
return "/notifications";
|
||||
}
|
||||
|
||||
if (item.targetType === "workflow_task" && canAccessApprovals) {
|
||||
return buildMobileApprovalsPath(item.targetId);
|
||||
}
|
||||
|
||||
if (item.targetType === "employee") {
|
||||
return "/mobile/me";
|
||||
}
|
||||
|
||||
if (item.targetType === "reimbursement_form") {
|
||||
return `/reimbursements/${item.targetId}`;
|
||||
}
|
||||
|
||||
return "/notifications";
|
||||
}
|
||||
|
||||
function QuickLinkCard({
|
||||
label,
|
||||
description,
|
||||
to,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
to: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
if (disabled) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-400">
|
||||
<div className="font-medium">{label}</div>
|
||||
<div className="mt-1 text-xs">{description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={to} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm text-zinc-700 shadow-sm transition-colors hover:bg-zinc-50">
|
||||
<div className="font-medium text-zinc-900">{label}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{description}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobilePortalPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const canAccessApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
||||
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
|
||||
const canAccessAssetScan = hasAnyPermission(permissions, [...MOBILE_ASSET_SCAN_PERMISSIONS]);
|
||||
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
|
||||
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
|
||||
|
||||
const presenceQuery = useQuery({
|
||||
queryKey: ["mobile", "portal", "presence"],
|
||||
queryFn: () => fetchMyPresence(),
|
||||
});
|
||||
|
||||
const pendingCountQuery = useQuery({
|
||||
enabled: canAccessApprovals,
|
||||
queryKey: ["mobile", "workflow", "pending-count"],
|
||||
queryFn: async () => {
|
||||
const response = await fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 1 });
|
||||
return response.total;
|
||||
},
|
||||
});
|
||||
|
||||
const notificationsQuery = useQuery({
|
||||
queryKey: ["mobile", "portal", "notifications"],
|
||||
queryFn: () => fetchNotifications({ page: 1, pageSize: 5 }),
|
||||
});
|
||||
|
||||
const presenceMutation = useMutation({
|
||||
mutationFn: (status: "office" | "remote" | "out") => upsertMyPresence({ status }),
|
||||
onSuccess: (response) => {
|
||||
queryClient.setQueryData(
|
||||
["mobile", "portal", "presence"],
|
||||
(current: { item: PresenceItem | null; date: string } | undefined) => ({
|
||||
item: response.item,
|
||||
date: current?.date ?? response.item.date,
|
||||
}),
|
||||
);
|
||||
toast.success("今日考勤状态已更新");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const presenceItem = presenceQuery.data?.item ?? null;
|
||||
const notifications = notificationsQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<PageScaffold className="space-y-4">
|
||||
<PageHeader
|
||||
title="移动首页"
|
||||
subtitle="处理今日考勤、审批和高频入口。"
|
||||
/>
|
||||
|
||||
<section className="card-std space-y-4 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-zinc-900">今日考勤状态</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{presenceQuery.isError ? "当前无法读取考勤状态" : `当前状态:${presenceStatusLabel(presenceItem)}`}
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge-neutral">{presenceQuery.data?.date ?? "今日"}</span>
|
||||
</div>
|
||||
|
||||
{presenceItem?.status === "leave" && presenceItem.source === "leave_sync" ? (
|
||||
<p className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
当前日期已被审批通过的请假覆盖,不能在移动首页手动修改。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{QUICK_PRESENCE_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={presenceItem?.status === option.value ? "default" : "secondary"}
|
||||
className="touch-target"
|
||||
disabled={presenceMutation.isPending || presenceQuery.isError || !canQuickUpdatePresence(presenceItem)}
|
||||
onClick={() => presenceMutation.mutate(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card-std space-y-3 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-zinc-900">待审批</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{canAccessApprovals ? "点击进入移动审批" : "当前账号没有移动审批权限"}
|
||||
</div>
|
||||
</div>
|
||||
<span className={canAccessApprovals ? "badge-warning" : "badge-neutral"}>
|
||||
{canAccessApprovals ? `${pendingCountQuery.data ?? 0} 条` : "不可用"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canAccessApprovals ? (
|
||||
<Button asChild type="button" className="w-full">
|
||||
<Link to="/mobile/approvals">进入移动审批</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-500">
|
||||
你当前没有审批权限,审批页入口已禁用。
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card-std space-y-3 p-4">
|
||||
<div className="text-sm font-semibold text-zinc-900">快捷入口</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<QuickLinkCard label="提交请假" description="进入请假申请页" to="/leave-requests" disabled={!canAccessLeave} />
|
||||
<QuickLinkCard label="提交报销" description="进入报销申请页" to="/reimbursements" disabled={!canAccessReimbursements} />
|
||||
<QuickLinkCard label="拍照上传" description="打开移动拍照上传" to="/mobile/capture" disabled={!canAccessCapture} />
|
||||
<QuickLinkCard label="资产扫描" description="进入扫码与盘点" to="/mobile/assets/scan" disabled={!canAccessAssetScan} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card-std space-y-3 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-zinc-900">最近通知</div>
|
||||
<Button asChild size="sm" type="button" variant="secondary">
|
||||
<Link to="/notifications">查看全部</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{notificationsQuery.isLoading ? <p className="text-sm text-zinc-500">加载通知中...</p> : null}
|
||||
{notificationsQuery.isError ? (
|
||||
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveErrorMessage(notificationsQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
{!notificationsQuery.isLoading && !notifications.length ? (
|
||||
<p className="text-sm text-zinc-500">暂无通知。</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
{notifications.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={resolveNotificationTargetPath(item, canAccessApprovals)}
|
||||
className="block rounded-2xl border border-zinc-200 bg-white px-4 py-3 text-sm shadow-sm transition-colors hover:bg-zinc-50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
{!item.readAt ? <span className="badge-warning shrink-0">未读</span> : null}
|
||||
</div>
|
||||
{item.body ? <div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-500">{item.body}</div> : null}
|
||||
<div className="mt-2 text-[11px] text-zinc-400">{formatDateTime(item.createdAt)}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户