R60 A1-A6:首页工作台优化
这个提交包含在:
@@ -8,12 +8,14 @@ import {
|
||||
fetchMyTodayAttendance,
|
||||
} from "../api/attendance";
|
||||
import { fetchNotifications } from "../api/notifications";
|
||||
import { fetchPortalTeamStatus } from "../api/portal";
|
||||
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 { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -53,6 +55,21 @@ function formatDateTime(value: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
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 presenceStatusLabel(item: PresenceItem | null) {
|
||||
if (!item) {
|
||||
return "未更新";
|
||||
@@ -123,6 +140,50 @@ function resolveNotificationTargetPath(
|
||||
return "/notifications";
|
||||
}
|
||||
|
||||
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 entityType || "审批";
|
||||
}
|
||||
|
||||
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" };
|
||||
}
|
||||
if (item.presenceStatus === "office") {
|
||||
return { label: "在岗", className: "badge-success" };
|
||||
}
|
||||
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 (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 (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",
|
||||
};
|
||||
}
|
||||
|
||||
function QuickLinkCard({
|
||||
label,
|
||||
description,
|
||||
@@ -183,11 +244,13 @@ export function MobilePortalPage() {
|
||||
|
||||
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;
|
||||
},
|
||||
queryKey: ["mobile", "workflow", "pending-items"],
|
||||
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 5 }),
|
||||
});
|
||||
|
||||
const teamStatusQuery = useQuery({
|
||||
queryKey: ["mobile", "portal", "team-status"],
|
||||
queryFn: () => fetchPortalTeamStatus(),
|
||||
});
|
||||
|
||||
const notificationsQuery = useQuery({
|
||||
@@ -249,6 +312,8 @@ export function MobilePortalPage() {
|
||||
const presenceItem = presenceQuery.data?.item ?? null;
|
||||
const attendanceToday = attendanceTodayQuery.data ?? null;
|
||||
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
|
||||
const teamStatusItems = teamStatusQuery.data ?? [];
|
||||
const pendingTasks = pendingCountQuery.data?.items ?? [];
|
||||
const notifications = notificationsQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
@@ -333,6 +398,48 @@ export function MobilePortalPage() {
|
||||
</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>
|
||||
@@ -369,20 +476,47 @@ export function MobilePortalPage() {
|
||||
<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="text-sm font-semibold text-zinc-900">待办聚合</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{canAccessApprovals ? "点击进入移动审批" : "当前账号没有移动审批权限"}
|
||||
{canAccessApprovals ? "展示前 5 条审批待办,可直接进入移动审批。" : "当前账号没有移动审批权限"}
|
||||
</div>
|
||||
</div>
|
||||
<span className={canAccessApprovals ? "badge-warning" : "badge-neutral"}>
|
||||
{canAccessApprovals ? `${pendingCountQuery.data ?? 0} 条` : "不可用"}
|
||||
{canAccessApprovals ? `${pendingCountQuery.data?.total ?? 0} 条` : "不可用"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canAccessApprovals ? (
|
||||
<Button asChild type="button" className="w-full">
|
||||
<Link to="/mobile/approvals">进入移动审批</Link>
|
||||
</Button>
|
||||
<>
|
||||
<Button asChild type="button" className="w-full">
|
||||
<Link to="/mobile/approvals">进入移动审批</Link>
|
||||
</Button>
|
||||
|
||||
{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">
|
||||
你当前没有审批权限,审批页入口已禁用。
|
||||
|
||||
在新工单中引用
屏蔽一个用户