392 行
14 KiB
TypeScript
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>
|
|
);
|
|
}
|