R96 U1-U7 精简页面设计与文案
这个提交包含在:
+200
-412
@@ -1,4 +1,3 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -6,33 +5,33 @@ import {
|
||||
clockInAttendance,
|
||||
clockOutAttendance,
|
||||
fetchMyTodayAttendance,
|
||||
type AttendanceTodayResponse,
|
||||
} from "../api/attendance";
|
||||
import { fetchNotifications } from "../api/notifications";
|
||||
import { fetchPortalTeamStatus } from "../api/portal";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchNotifications } from "../api/notifications";
|
||||
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 { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||
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 },
|
||||
];
|
||||
type MobileAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
meta: string;
|
||||
icon: string;
|
||||
to: string;
|
||||
tone?: "primary" | "warning";
|
||||
};
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
if (isApiError(error)) {
|
||||
@@ -44,12 +43,22 @@ function resolveErrorMessage(error: unknown) {
|
||||
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");
|
||||
return new Date(value).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
@@ -70,44 +79,6 @@ function formatShortTime(value: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
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 isAttendanceFeatureDisabled(error: unknown) {
|
||||
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
|
||||
}
|
||||
|
||||
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 formatWorkMinutes(value: number | null | undefined) {
|
||||
if (value == null) {
|
||||
return "-";
|
||||
@@ -117,27 +88,13 @@ function formatWorkMinutes(value: number | null | undefined) {
|
||||
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
||||
}
|
||||
|
||||
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 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) {
|
||||
@@ -145,69 +102,46 @@ function workflowEntityLabel(entityType: string | null | undefined) {
|
||||
if (entityType === "reimbursement") return "报销";
|
||||
if (entityType === "purchase_request") return "采购";
|
||||
if (entityType === "travel_request") return "差旅";
|
||||
return entityType || "审批";
|
||||
return "审批";
|
||||
}
|
||||
|
||||
function resolveTeamStatusBadge(item: {
|
||||
onLeave: boolean;
|
||||
leaveType: string | null;
|
||||
presenceStatus: string | null;
|
||||
attendanceClockIn: string | null;
|
||||
}) {
|
||||
if (item.onLeave) {
|
||||
return { label: item.leaveType ? `请假 · ${item.leaveType}` : "请假", className: "badge-warning" };
|
||||
function resolveAttendanceAction(attendanceToday: AttendanceTodayResponse | null) {
|
||||
if (!attendanceToday) {
|
||||
return null;
|
||||
}
|
||||
if (item.presenceStatus === "office") {
|
||||
return { label: "在岗", className: "badge-success" };
|
||||
if (!attendanceToday.needClockIn) {
|
||||
return null;
|
||||
}
|
||||
if (item.presenceStatus === "out") {
|
||||
return {
|
||||
label: "外出",
|
||||
className: "inline-flex items-center rounded-full bg-sky-50 px-2 py-0.5 text-[11px] font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10",
|
||||
};
|
||||
if (!attendanceToday.item?.clockInAt) {
|
||||
return "clock-in" as const;
|
||||
}
|
||||
if (item.presenceStatus === "travel") {
|
||||
return {
|
||||
label: "差旅",
|
||||
className: "inline-flex items-center rounded-full bg-sky-50 px-2 py-0.5 text-[11px] font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10",
|
||||
};
|
||||
if (!attendanceToday.item.clockOutAt) {
|
||||
return "clock-out" as const;
|
||||
}
|
||||
if (item.presenceStatus === "remote") {
|
||||
return { label: "居家", className: "badge-neutral" };
|
||||
}
|
||||
if (!item.attendanceClockIn) {
|
||||
return { label: "未打卡", className: "badge-neutral" };
|
||||
}
|
||||
return {
|
||||
label: "已打卡",
|
||||
className: "inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-700 ring-1 ring-inset ring-slate-600/10",
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
function ActionTile({ action }: { action: MobileAction }) {
|
||||
const isPrimary = action.tone === "primary";
|
||||
const isWarning = action.tone === "warning";
|
||||
|
||||
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
|
||||
to={action.to}
|
||||
className={
|
||||
isPrimary
|
||||
? "flex min-h-24 flex-col justify-between rounded-lg bg-zinc-900 p-3 text-white 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-white/75" : "text-xs text-zinc-500"}>{action.meta}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -215,27 +149,11 @@ function QuickLinkCard({
|
||||
export function MobilePortalPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const [nowLabel, setNowLabel] = useState(() =>
|
||||
new Date().toLocaleTimeString("zh-CN", { hour12: false }),
|
||||
);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setNowLabel(new Date().toLocaleTimeString("zh-CN", { hour12: false }));
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const presenceQuery = useQuery({
|
||||
queryKey: ["mobile", "portal", "presence"],
|
||||
queryFn: () => fetchMyPresence(),
|
||||
});
|
||||
|
||||
const attendanceTodayQuery = useQuery({
|
||||
queryKey: ["mobile", "portal", "attendance", "today"],
|
||||
queryFn: () => fetchMyTodayAttendance(),
|
||||
@@ -245,34 +163,12 @@ export function MobilePortalPage() {
|
||||
const pendingCountQuery = useQuery({
|
||||
enabled: canAccessApprovals,
|
||||
queryKey: ["mobile", "workflow", "pending-items"],
|
||||
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 5 }),
|
||||
});
|
||||
|
||||
const teamStatusQuery = useQuery({
|
||||
queryKey: ["mobile", "portal", "team-status"],
|
||||
queryFn: () => fetchPortalTeamStatus(),
|
||||
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 3 }),
|
||||
});
|
||||
|
||||
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));
|
||||
},
|
||||
queryFn: () => fetchNotifications({ page: 1, pageSize: 3 }),
|
||||
});
|
||||
|
||||
const attendanceClockInMutation = useMutation({
|
||||
@@ -283,11 +179,7 @@ export function MobilePortalPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
|
||||
]);
|
||||
toast.success(
|
||||
response.item.isLate
|
||||
? `上班打卡成功,已迟到 ${response.item.lateMinutes} 分钟`
|
||||
: "上班打卡成功",
|
||||
);
|
||||
toast.success(response.item.isLate ? `上班打卡成功,迟到 ${response.item.lateMinutes} 分钟` : "上班打卡成功");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
@@ -309,273 +201,170 @@ export function MobilePortalPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const presenceItem = presenceQuery.data?.item ?? null;
|
||||
const attendanceToday = attendanceTodayQuery.data ?? null;
|
||||
const attendanceAction = resolveAttendanceAction(attendanceToday);
|
||||
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
|
||||
const teamStatusItems = teamStatusQuery.data ?? [];
|
||||
const pendingApprovalCount = canAccessApprovals ? pendingCountQuery.data?.total ?? 0 : 0;
|
||||
const pendingTasks = pendingCountQuery.data?.items ?? [];
|
||||
const notifications = notificationsQuery.data?.items ?? [];
|
||||
const pendingApprovalCount = canAccessApprovals ? pendingCountQuery.data?.total ?? 0 : 0;
|
||||
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: "拍照或选文件",
|
||||
icon: "receipt_long",
|
||||
to: "/mobile/capture",
|
||||
tone: "primary",
|
||||
}
|
||||
: null,
|
||||
canAccessReimbursements
|
||||
? {
|
||||
key: "reimbursements",
|
||||
label: "报销",
|
||||
meta: "新建和查进度",
|
||||
icon: "request_quote",
|
||||
to: "/reimbursements",
|
||||
}
|
||||
: null,
|
||||
canAccessLeave
|
||||
? {
|
||||
key: "leave",
|
||||
label: "请假",
|
||||
meta: "提交申请",
|
||||
icon: "event_available",
|
||||
to: "/leave-requests",
|
||||
}
|
||||
: 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="今天"
|
||||
subtitle="先处理待办,再进入报销、票据和团队事项。"
|
||||
/>
|
||||
<PageHeader title="今天" />
|
||||
|
||||
<section className="card-std space-y-4 p-4" data-testid="mobile-today-focus">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-zinc-900">今日重点</div>
|
||||
<div className="mt-1 text-xs leading-5 text-zinc-500">移动端只保留高频动作,低频模块放到“我的”。</div>
|
||||
</div>
|
||||
<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>
|
||||
<span className={pendingApprovalCount > 0 ? "badge-warning" : "badge-success"}>
|
||||
{pendingApprovalCount > 0 ? `${pendingApprovalCount} 条待审批` : "暂无审批待办"}
|
||||
{pendingApprovalCount > 0 ? `${pendingApprovalCount} 待批` : "清爽"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
|
||||
<div className="text-2xl font-semibold text-zinc-900">{pendingApprovalCount}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">审批待办</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
|
||||
<div className="text-2xl font-semibold text-zinc-900">{unreadNotificationCount}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">未读通知</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<QuickLinkCard label="处理审批" description="查看待办并快速处理" to="/mobile/approvals" disabled={!canAccessApprovals} />
|
||||
<QuickLinkCard label="发起报销" description="提交报销和查看进度" to="/reimbursements" disabled={!canAccessReimbursements} />
|
||||
<QuickLinkCard label="上传票据" description="拍照或选择文件上传" to="/mobile/capture" disabled={!canAccessCapture} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!attendanceFeatureDisabled ? (
|
||||
<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">
|
||||
{attendanceToday?.workDate ?? "今日"} · 当前时间 {nowLabel}
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge-neutral">{attendanceToday?.workDate ?? "今日"}</span>
|
||||
</div>
|
||||
|
||||
{attendanceTodayQuery.isLoading ? (
|
||||
<p className="text-sm text-zinc-500">加载今日考勤中...</p>
|
||||
) : attendanceTodayQuery.isError ? (
|
||||
<p className="rounded-xl 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="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
|
||||
<div className="text-base font-semibold text-zinc-900">{attendanceExemptionLabel(attendanceToday.exemptionReason)}</div>
|
||||
<div className="mt-2 text-sm text-zinc-500">
|
||||
{attendanceToday.item?.clockInAt || attendanceToday.item?.clockOutAt
|
||||
? `已有补录记录:${formatDateTime(attendanceToday.item.clockInAt)} / ${formatDateTime(attendanceToday.item.clockOutAt)}`
|
||||
: "系统已自动排除缺卡统计。"}
|
||||
</div>
|
||||
</div>
|
||||
) : attendanceToday?.item?.clockInAt && !attendanceToday.item.clockOutAt ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-4 text-sm text-amber-700">
|
||||
已打上班卡:{formatDateTime(attendanceToday.item.clockInAt)}
|
||||
{attendanceToday.item.isLate ? `,迟到 ${attendanceToday.item.lateMinutes} 分钟` : ""}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full bg-amber-500 text-white hover:bg-amber-600"
|
||||
disabled={attendanceClockOutMutation.isPending}
|
||||
onClick={() => attendanceClockOutMutation.mutate()}
|
||||
>
|
||||
下班打卡
|
||||
</Button>
|
||||
</div>
|
||||
) : attendanceToday?.item?.clockInAt && attendanceToday.item.clockOutAt ? (
|
||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
|
||||
<div className="text-base font-semibold text-zinc-900">今日已完成</div>
|
||||
<div className="mt-2 text-sm text-zinc-600">
|
||||
上班:{formatDateTime(attendanceToday.item.clockInAt)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-600">
|
||||
下班:{formatDateTime(attendanceToday.item.clockOutAt)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-600">
|
||||
工时:{formatWorkMinutes(attendanceToday.item.workMinutes)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-700">
|
||||
今日尚未打上班卡。
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
disabled={attendanceClockInMutation.isPending}
|
||||
onClick={() => attendanceClockInMutation.mutate()}
|
||||
>
|
||||
上班打卡
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<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">查看全员今日在岗、请假和打卡状态。</div>
|
||||
</div>
|
||||
<span className="badge-neutral">{teamStatusItems.length} 人</span>
|
||||
</div>
|
||||
|
||||
{teamStatusQuery.isLoading ? <p className="text-sm text-zinc-500">加载团队状态中...</p> : null}
|
||||
{teamStatusQuery.isError ? (
|
||||
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveErrorMessage(teamStatusQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
{!teamStatusQuery.isLoading && !teamStatusQuery.isError && !teamStatusItems.length ? (
|
||||
<p className="text-sm text-zinc-500">暂无团队状态。</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{teamStatusItems.map((item) => {
|
||||
const badge = resolveTeamStatusBadge(item);
|
||||
return (
|
||||
<div key={item.employeeId} className="rounded-2xl border border-zinc-200 bg-white px-3 py-3 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-zinc-900">{item.name}</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">
|
||||
{item.attendanceClockIn ? `${formatShortTime(item.attendanceClockIn)} 已打卡` : "暂无打卡"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className={badge.className}>{badge.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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 className="grid grid-cols-2 gap-2">
|
||||
{actions.map((action) => (
|
||||
<ActionTile key={action.key} action={action} />
|
||||
))}
|
||||
</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 ? "展示前 5 条审批待办,可直接进入移动审批。" : "当前账号没有移动审批权限"}
|
||||
</div>
|
||||
{!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>
|
||||
<span className={canAccessApprovals ? "badge-warning" : "badge-neutral"}>
|
||||
{canAccessApprovals ? `${pendingCountQuery.data?.total ?? 0} 条` : "不可用"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canAccessApprovals ? (
|
||||
<>
|
||||
<Button asChild type="button" className="w-full">
|
||||
<Link to="/mobile/approvals">进入移动审批</Link>
|
||||
{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}
|
||||
|
||||
{pendingCountQuery.isLoading ? <p className="text-sm text-zinc-500">加载待办中...</p> : null}
|
||||
{pendingCountQuery.isError ? (
|
||||
<p className="rounded-xl 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-2xl border border-zinc-200 bg-white px-4 py-3 text-sm shadow-sm transition-colors hover:bg-zinc-50"
|
||||
>
|
||||
<div className="font-medium text-zinc-900">{task.stepName}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
业务:{workflowEntityLabel(task.workflowInstance?.entityType)} · 实体:{task.workflowInstance?.entityId ?? "-"}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-500">
|
||||
你当前没有审批权限,审批页入口已禁用。
|
||||
{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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{(canAccessLeave || canAccessAssetScan) ? (
|
||||
<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="/mobile/assets/scan" disabled={!canAccessAssetScan} />
|
||||
{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="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>
|
||||
<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="/notifications">查看全部</Link>
|
||||
<Link to="/notifications">全部</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{notificationsQuery.isLoading ? <p className="text-sm text-zinc-500">加载通知中...</p> : null}
|
||||
{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">
|
||||
<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}
|
||||
@@ -587,15 +376,14 @@ export function MobilePortalPage() {
|
||||
{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"
|
||||
to="/notifications"
|
||||
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
|
||||
>
|
||||
<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 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>
|
||||
{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>
|
||||
<div className="mt-1 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
在新工单中引用
屏蔽一个用户