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

392 行
14 KiB
TypeScript

import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
clockInAttendance,
clockOutAttendance,
fetchMyTodayAttendance,
type AttendanceTodayResponse,
} from "../api/attendance";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchNotifications } from "../api/notifications";
import { isApiError } from "../api/types";
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_CAPTURE_PERMISSIONS,
MOBILE_LEAVE_PERMISSIONS,
MOBILE_REIMBURSEMENT_PERMISSIONS,
} from "../features/mobile/navigation";
import { buildMobileApprovalsPath } from "../pwa";
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 isAttendanceFeatureDisabled(error: unknown) {
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
}
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 formatShortTime(value: string | null) {
if (!value) {
return "--:--";
}
try {
return new Date(value).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
} catch {
return value;
}
}
function formatWorkMinutes(value: number | null | undefined) {
if (value == null) {
return "-";
}
const hours = Math.floor(value / 60);
const minutes = value % 60;
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
}
function attendanceExemptionLabel(reason: unknown) {
if (reason === "leave") return "今日请假";
if (reason === "travel") return "今日差旅";
if (reason === "holiday") return "节假日";
if (reason === "weekend") return "周末";
if (reason === "policy_disabled") return "无需打卡";
return "无需打卡";
}
function workflowEntityLabel(entityType: string | null | undefined) {
if (entityType === "leave") return "请假";
if (entityType === "reimbursement") return "报销";
if (entityType === "purchase_request") return "采购";
if (entityType === "travel_request") return "差旅";
return "审批";
}
function resolveAttendanceAction(attendanceToday: AttendanceTodayResponse | null) {
if (!attendanceToday) {
return null;
}
if (!attendanceToday.needClockIn) {
return null;
}
if (!attendanceToday.item?.clockInAt) {
return "clock-in" as const;
}
if (!attendanceToday.item.clockOutAt) {
return "clock-out" as const;
}
return null;
}
function ActionTile({ action }: { action: MobileAction }) {
const isPrimary = action.tone === "primary";
const isWarning = action.tone === "warning";
return (
<Link
to={action.to}
className={
isPrimary
? "flex min-h-24 flex-col justify-between rounded-lg border border-primary-100 bg-primary-50 p-3 text-primary shadow-sm"
: isWarning
? "flex min-h-24 flex-col justify-between rounded-lg border border-amber-200 bg-amber-50 p-3 text-amber-900"
: "flex min-h-24 flex-col justify-between rounded-lg border border-zinc-200 bg-white p-3 text-zinc-900 shadow-sm"
}
data-testid={`mobile-action-${action.key}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold">{action.label}</span>
<span className="material-symbols-outlined text-[20px]">{action.icon}</span>
</div>
<div className={isPrimary ? "text-xs text-primary/70" : "text-xs text-zinc-500"}>{action.meta}</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 canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
const attendanceTodayQuery = useQuery({
queryKey: ["mobile", "portal", "attendance", "today"],
queryFn: () => fetchMyTodayAttendance(),
retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2,
});
const pendingCountQuery = useQuery({
enabled: canAccessApprovals,
queryKey: ["mobile", "workflow", "pending-items"],
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 3 }),
});
const notificationsQuery = useQuery({
queryKey: ["mobile", "portal", "notifications"],
queryFn: () => fetchNotifications({ page: 1, pageSize: 3 }),
});
const attendanceClockInMutation = useMutation({
mutationFn: () => clockInAttendance(),
onSuccess: async (response) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mobile", "portal", "attendance", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
]);
toast.success(response.item.isLate ? `上班打卡成功,迟到 ${response.item.lateMinutes} 分钟` : "上班打卡成功");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const attendanceClockOutMutation = useMutation({
mutationFn: () => clockOutAttendance(),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mobile", "portal", "attendance", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
]);
toast.success("下班打卡成功");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const attendanceToday = attendanceTodayQuery.data ?? null;
const attendanceAction = resolveAttendanceAction(attendanceToday);
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
const pendingApprovalCount = canAccessApprovals ? pendingCountQuery.data?.total ?? 0 : 0;
const pendingTasks = pendingCountQuery.data?.items ?? [];
const notifications = notificationsQuery.data?.items ?? [];
const unreadNotificationCount = notifications.filter((item) => !item.readAt).length;
const actions: MobileAction[] = [
canAccessApprovals
? {
key: "approvals",
label: "审批",
meta: pendingApprovalCount > 0 ? `${pendingApprovalCount} 条待处理` : "暂无待办",
icon: "fact_check",
to: "/mobile/approvals",
tone: pendingApprovalCount > 0 ? "warning" : "primary",
}
: null,
canAccessCapture
? {
key: "capture",
label: "传票据",
meta: "上传 PDF",
icon: "receipt_long",
to: "/mobile/capture",
tone: "primary",
}
: null,
canAccessReimbursements
? {
key: "reimbursements",
label: "报销",
meta: "新建与进度",
icon: "request_quote",
to: "/mobile/reimbursements",
}
: null,
canAccessLeave
? {
key: "leave",
label: "请假",
meta: "提交申请",
icon: "event_available",
to: "/mobile/leave",
}
: null,
{
key: "me",
label: "我的",
meta: unreadNotificationCount > 0 ? `${unreadNotificationCount} 条新通知` : "资料和通知",
icon: "person",
to: "/mobile/me",
},
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
return (
<PageScaffold className="space-y-4">
<PageHeader title="今天" />
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-today-focus">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">待处理</div>
{pendingApprovalCount > 0 ? <span className="badge-warning">{pendingApprovalCount} 待批</span> : null}
</div>
<div className="grid grid-cols-2 gap-2">
{actions.map((action) => (
<ActionTile key={action.key} action={action} />
))}
</div>
</section>
{!attendanceFeatureDisabled ? (
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-attendance-card">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">打卡</div>
<span className="badge-neutral">{attendanceToday?.workDate ?? "今日"}</span>
</div>
{attendanceTodayQuery.isLoading ? (
<p className="text-sm text-zinc-500">读取中...</p>
) : attendanceTodayQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(attendanceTodayQuery.error)}
</p>
) : attendanceToday && !attendanceToday.needClockIn ? (
<div className="text-sm text-zinc-600">{attendanceExemptionLabel(attendanceToday.exemptionReason)}</div>
) : attendanceToday?.item?.clockInAt && attendanceToday.item.clockOutAt ? (
<div className="text-sm text-zinc-600">
{formatShortTime(attendanceToday.item.clockInAt)} - {formatShortTime(attendanceToday.item.clockOutAt)}
<span className="ml-2 text-zinc-400">工时 {formatWorkMinutes(attendanceToday.item.workMinutes)}</span>
</div>
) : attendanceToday?.item?.clockInAt ? (
<div className="text-sm text-zinc-600">已上班 {formatShortTime(attendanceToday.item.clockInAt)}</div>
) : (
<div className="text-sm text-zinc-600">今天还没打卡</div>
)}
{attendanceAction ? (
<Button
type="button"
className="mt-3 w-full"
disabled={attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending}
onClick={() => {
if (attendanceAction === "clock-in") {
attendanceClockInMutation.mutate();
} else {
attendanceClockOutMutation.mutate();
}
}}
>
{attendanceAction === "clock-in" ? "上班打卡" : "下班打卡"}
</Button>
) : null}
</section>
) : null}
{canAccessApprovals ? (
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-pending-tasks">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">待审批</div>
<Button asChild size="sm" type="button" variant="secondary">
<Link to="/mobile/approvals">全部</Link>
</Button>
</div>
{pendingCountQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
{pendingCountQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(pendingCountQuery.error)}
</p>
) : null}
{!pendingCountQuery.isLoading && !pendingTasks.length ? (
<p className="text-sm text-zinc-500">没有待处理审批。</p>
) : null}
<div className="space-y-2">
{pendingTasks.map((task) => (
<Link
key={task.id}
to={buildMobileApprovalsPath(task.id)}
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
>
<div className="font-medium text-zinc-900">{task.stepName}</div>
<div className="mt-1 text-xs text-zinc-500">{workflowEntityLabel(task.workflowInstance?.entityType)}</div>
</Link>
))}
</div>
</section>
) : null}
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-notifications">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">通知</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 && !notifications.length ? (
<p className="text-sm text-zinc-500">暂无通知。</p>
) : null}
<div className="space-y-2">
{notifications.map((item) => (
<Link
key={item.id}
to="/mobile/notifications"
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
>
<div className="flex items-center justify-between gap-3">
<div className="truncate font-medium text-zinc-900">{item.title}</div>
{!item.readAt ? <span className="badge-warning shrink-0">新</span> : null}
</div>
<div className="mt-1 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
</Link>
))}
</div>
</section>
</PageScaffold>
);
}