R60 A1-A6:首页工作台优化
这个提交包含在:
@@ -1,10 +1,23 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchPortalHome, type PortalHomePayload } from "../api/portal";
|
||||
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 {
|
||||
fetchPortalHome,
|
||||
type PortalHomePayload,
|
||||
type PortalRecentActivity,
|
||||
type PortalTeamStatusItem,
|
||||
} from "../api/portal";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission, isManagerRole } from "../auth/permissions";
|
||||
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||
import { EmptyStateCard } from "../components/common/EmptyStateCard";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
@@ -23,6 +36,32 @@ type SummaryItem = {
|
||||
tone?: "normal" | "warning";
|
||||
};
|
||||
|
||||
type TodoAggregateItem = {
|
||||
title: string;
|
||||
sourceLabel: string;
|
||||
severity: "normal" | "warning" | "high";
|
||||
href: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
type QuickActionConfig = {
|
||||
key: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
description: string;
|
||||
to?: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const LEAVE_PORTAL_PERMISSIONS = ["leave.view_own", "leave.view_all", "leave.create", "leave.submit"];
|
||||
const EXPENSE_PORTAL_PERMISSIONS = ["expense.view_own", "expense.view_all", "expense.create", "expense.proxy_manage"];
|
||||
const TODO_SEVERITY_RANK = {
|
||||
high: 3,
|
||||
warning: 2,
|
||||
normal: 1,
|
||||
} as const;
|
||||
|
||||
function isSummaryItem(item: SummaryItem | null): item is SummaryItem {
|
||||
return Boolean(item);
|
||||
}
|
||||
@@ -47,6 +86,251 @@ function formatWeeklyStatus(payload: PortalHomePayload["weeklyReport"]) {
|
||||
return payload.status === "submitted" ? "已提交" : "待填写";
|
||||
}
|
||||
|
||||
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 formatRelativeTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const timestamp = new Date(value).getTime();
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const diffMs = Date.now() - timestamp;
|
||||
if (diffMs < 60 * 1000) {
|
||||
return "刚刚";
|
||||
}
|
||||
|
||||
const diffMinutes = Math.floor(diffMs / (60 * 1000));
|
||||
if (diffMinutes < 60) {
|
||||
return `${diffMinutes} 分钟前`;
|
||||
}
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours} 小时前`;
|
||||
}
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays} 天前`;
|
||||
}
|
||||
|
||||
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 buildTodoAggregateItems(payload: PortalHomePayload) {
|
||||
const items: TodoAggregateItem[] = [];
|
||||
if (payload.pendingTasks.count > 0) {
|
||||
items.push({
|
||||
title: `审批待办 ${payload.pendingTasks.count} 条`,
|
||||
sourceLabel: "审批",
|
||||
severity: "warning",
|
||||
href: payload.pendingTasks.href,
|
||||
icon: "fact_check",
|
||||
});
|
||||
}
|
||||
if ((payload.todoSummary?.openCount ?? 0) > 0) {
|
||||
items.push({
|
||||
title: `TODO 待办 ${payload.todoSummary?.openCount ?? 0} 条`,
|
||||
sourceLabel: "TODO",
|
||||
severity: (payload.todoSummary?.highPriorityCount ?? 0) > 0 ? "high" : "normal",
|
||||
href: payload.todoSummary?.href ?? "/todos",
|
||||
icon: "check_circle",
|
||||
});
|
||||
}
|
||||
for (const card of payload.cards) {
|
||||
if (!card.severity || (card.severity !== "warning" && card.severity !== "high")) {
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
title: `${card.title}:${card.value}`,
|
||||
sourceLabel: "工作台",
|
||||
severity: card.severity,
|
||||
href: card.href,
|
||||
icon: card.severity === "high" ? "priority_high" : "warning",
|
||||
});
|
||||
}
|
||||
|
||||
const deduped = new Map<string, TodoAggregateItem>();
|
||||
for (const item of items) {
|
||||
deduped.set(`${item.title}|${item.href}|${item.sourceLabel}`, item);
|
||||
}
|
||||
|
||||
return Array.from(deduped.values())
|
||||
.sort((left, right) => {
|
||||
const severityCompare = TODO_SEVERITY_RANK[right.severity] - TODO_SEVERITY_RANK[left.severity];
|
||||
if (severityCompare !== 0) {
|
||||
return severityCompare;
|
||||
}
|
||||
return left.title.localeCompare(right.title, "zh-CN");
|
||||
})
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
function resolveTeamStatusBadge(item: PortalTeamStatusItem) {
|
||||
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.5 py-0.5 text-xs 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.5 py-0.5 text-xs 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.5 py-0.5 text-xs font-semibold text-slate-700 ring-1 ring-inset ring-slate-600/10",
|
||||
};
|
||||
}
|
||||
|
||||
function formatTeamStatusAttendance(item: PortalTeamStatusItem) {
|
||||
if (item.onLeave) {
|
||||
return item.leaveType ? `${item.leaveType} 已审批` : "今日请假";
|
||||
}
|
||||
if (item.attendanceClockIn && item.attendanceClockOut) {
|
||||
return `${formatShortTime(item.attendanceClockIn)} - ${formatShortTime(item.attendanceClockOut)}`;
|
||||
}
|
||||
if (item.attendanceClockIn) {
|
||||
return `${formatShortTime(item.attendanceClockIn)} 已打卡`;
|
||||
}
|
||||
if (item.presenceStatus === "travel") {
|
||||
return "今日差旅";
|
||||
}
|
||||
if (item.presenceStatus === "out") {
|
||||
return "当前外出";
|
||||
}
|
||||
if (item.presenceStatus === "remote") {
|
||||
return "今日居家办公";
|
||||
}
|
||||
return "暂无打卡记录";
|
||||
}
|
||||
|
||||
function resolveClockAction(
|
||||
attendanceToday: AttendanceTodayResponse | null,
|
||||
state: {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
featureDisabled: boolean;
|
||||
pending: boolean;
|
||||
},
|
||||
) {
|
||||
if (state.pending) {
|
||||
return {
|
||||
label: "处理中...",
|
||||
description: "正在提交考勤动作",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (state.isLoading) {
|
||||
return {
|
||||
label: "打卡读取中",
|
||||
description: "正在读取今日考勤状态",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (state.isError && !state.featureDisabled) {
|
||||
return {
|
||||
label: "打卡不可用",
|
||||
description: "当前无法读取今日考勤状态",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (!attendanceToday) {
|
||||
return {
|
||||
label: "今日考勤",
|
||||
description: "暂无可用考勤数据",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (!attendanceToday.needClockIn) {
|
||||
return {
|
||||
label: "今日无需打卡",
|
||||
description: "系统已自动排除缺卡统计",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (!attendanceToday.item?.clockInAt) {
|
||||
return {
|
||||
label: "上班打卡",
|
||||
description: "记录今日到岗时间",
|
||||
disabled: false,
|
||||
action: "clock-in",
|
||||
} as const;
|
||||
}
|
||||
if (!attendanceToday.item.clockOutAt) {
|
||||
return {
|
||||
label: "下班打卡",
|
||||
description: `已上班 ${formatShortTime(attendanceToday.item.clockInAt)}`,
|
||||
disabled: false,
|
||||
action: "clock-out",
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
label: "今日已完成",
|
||||
description: `工时 ${formatWorkMinutes(attendanceToday.item.workMinutes)}`,
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) {
|
||||
if (!items.length) {
|
||||
return <EmptyStateCard title={title} description="暂无需要优先处理的摘要信息。" />;
|
||||
@@ -197,12 +481,196 @@ function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullabl
|
||||
);
|
||||
}
|
||||
|
||||
function QuickActionTile({
|
||||
icon,
|
||||
label,
|
||||
description,
|
||||
to,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: QuickActionConfig) {
|
||||
const baseClassName = disabled
|
||||
? "rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-left text-zinc-400"
|
||||
: "rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition-colors hover:bg-zinc-50";
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={disabled
|
||||
? "flex size-10 items-center justify-center rounded-2xl bg-zinc-100 text-zinc-400"
|
||||
: "flex size-10 items-center justify-center rounded-2xl bg-primary/10 text-primary"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">{icon}</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className={disabled ? "font-medium text-zinc-500" : "font-medium text-zinc-900"}>{label}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (to && !disabled) {
|
||||
return <Link to={to} className={baseClassName}>{content}</Link>;
|
||||
}
|
||||
if (onClick) {
|
||||
return (
|
||||
<button type="button" className={baseClassName} disabled={disabled} onClick={onClick}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <div className={baseClassName}>{content}</div>;
|
||||
}
|
||||
|
||||
function QuickActionsPanel({
|
||||
items,
|
||||
}: {
|
||||
items: QuickActionConfig[];
|
||||
}) {
|
||||
return (
|
||||
<SectionPanel title="快捷操作栏" description="把每天高频动作直接前置到首页,不再绕路。">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{items.length ? items.map((item) => (
|
||||
<QuickActionTile
|
||||
key={item.key}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
description={item.description}
|
||||
to={item.to}
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
)) : <EmptyStateCard title="暂无快捷动作" description="当前账号没有可直接执行的首页动作。" />}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
|
||||
return (
|
||||
<SectionPanel title="今日待办聚合" description="合并审批、TODO 和高风险指标,只保留今天最该处理的事。">
|
||||
<div className="space-y-3">
|
||||
{items.length ? items.map((item) => (
|
||||
<Link key={`${item.title}-${item.href}-${item.sourceLabel}`} to={item.href} className="block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={item.severity === "high"
|
||||
? "mt-0.5 flex size-9 items-center justify-center rounded-2xl bg-red-50 text-red-600"
|
||||
: item.severity === "warning"
|
||||
? "mt-0.5 flex size-9 items-center justify-center rounded-2xl bg-amber-50 text-amber-600"
|
||||
: "mt-0.5 flex size-9 items-center justify-center rounded-2xl bg-zinc-100 text-zinc-600"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">{item.icon}</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<span className="badge-neutral">{item.sourceLabel}</span>
|
||||
{item.severity === "high" ? <span className="badge-error">高优先</span> : null}
|
||||
{item.severity === "warning" ? <span className="badge-warning">关注</span> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-primary">进入处理</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)) : <EmptyStateCard title="暂无待办事项,休息一下吧" description="当前没有需要立刻处理的审批、TODO 或风险提醒。" />}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) {
|
||||
return (
|
||||
<SectionPanel title="团队全员状态" description="同时看人在不在岗、有没有请假,以及今天是否已打卡。">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{items.length ? items.map((item) => {
|
||||
const badge = resolveTeamStatusBadge(item);
|
||||
return (
|
||||
<div key={item.employeeId} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-zinc-900">{item.name}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{formatTeamStatusAttendance(item)}</div>
|
||||
</div>
|
||||
<span className={badge.className}>{badge.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : <EmptyStateCard title="暂无团队状态" description="当前没有可展示的团队成员状态。" />}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentActivitiesPanel({ items }: { items: PortalRecentActivity[] }) {
|
||||
return (
|
||||
<SectionPanel title="近期动态流" description="把最近发生的报销、请假、考勤和资产动作放在同一条时间线上。">
|
||||
<div className="space-y-3">
|
||||
{items.length ? items.map((item) => (
|
||||
<Link key={item.id} to={item.href} className="block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<EmployeeAvatar name={item.actorName} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium leading-6 text-zinc-900">{item.summary}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{formatRelativeTime(item.createdAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)) : <EmptyStateCard title="暂无近期动态" description="当前没有可展示的最近业务动作。" />}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortalHomePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
|
||||
const attendanceTodayQuery = useQuery({
|
||||
queryKey: ["portal-home", "attendance", "today"],
|
||||
queryFn: () => fetchMyTodayAttendance(),
|
||||
retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2,
|
||||
});
|
||||
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
|
||||
const isManagerView = isManagerRole(permissions);
|
||||
|
||||
const attendanceClockInMutation = useMutation({
|
||||
mutationFn: () => clockInAttendance(),
|
||||
onSuccess: async (response) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home", "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: ["portal-home"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home", "attendance", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
|
||||
]);
|
||||
toast.success("下班打卡成功");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
if (portalQuery.isLoading) {
|
||||
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
|
||||
}
|
||||
@@ -219,6 +687,15 @@ export function PortalHomePage() {
|
||||
const primaryCards = pickCards(payload.cards, portalSchema.heroCards);
|
||||
const primaryCardKeySet = primaryCards.map((card) => card.key);
|
||||
const secondaryCards = compactCards(payload.cards, primaryCardKeySet);
|
||||
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
|
||||
const attendanceToday = attendanceTodayQuery.data ?? null;
|
||||
const clockAction = resolveClockAction(attendanceToday, {
|
||||
isLoading: attendanceTodayQuery.isLoading,
|
||||
isError: attendanceTodayQuery.isError,
|
||||
featureDisabled: attendanceFeatureDisabled,
|
||||
pending: attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending,
|
||||
});
|
||||
const todoAggregateItems = buildTodoAggregateItems(payload);
|
||||
|
||||
const employeeSummaryItems = ([
|
||||
{
|
||||
@@ -272,6 +749,49 @@ export function PortalHomePage() {
|
||||
: null,
|
||||
] as Array<SummaryItem | null>).filter(isSummaryItem);
|
||||
|
||||
const quickActions: QuickActionConfig[] = [];
|
||||
if (hasAnyPermission(permissions, LEAVE_PORTAL_PERMISSIONS)) {
|
||||
quickActions.push({
|
||||
key: "leave",
|
||||
icon: "event_available",
|
||||
label: "提交请假",
|
||||
description: "直接进入请假申请页",
|
||||
to: "/leave-requests?action=create",
|
||||
});
|
||||
}
|
||||
if (hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) {
|
||||
quickActions.push({
|
||||
key: "expense",
|
||||
icon: "request_quote",
|
||||
label: "提交报销",
|
||||
description: "进入报销工作台创建单据",
|
||||
to: "/reimbursements",
|
||||
});
|
||||
}
|
||||
if (!attendanceFeatureDisabled) {
|
||||
quickActions.push({
|
||||
key: "attendance",
|
||||
icon: clockAction.action === "clock-out" ? "logout" : "schedule",
|
||||
label: clockAction.label,
|
||||
description: clockAction.description,
|
||||
disabled: clockAction.disabled,
|
||||
onClick: clockAction.action === "clock-in"
|
||||
? () => attendanceClockInMutation.mutate()
|
||||
: clockAction.action === "clock-out"
|
||||
? () => attendanceClockOutMutation.mutate()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
if (payload.weeklyReport || payload.weeklySummary) {
|
||||
quickActions.push({
|
||||
key: "weekly",
|
||||
icon: "article",
|
||||
label: "提交周报",
|
||||
description: "进入周报页继续填写或查看团队缺口",
|
||||
to: "/weekly-reports",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<PageScaffold>
|
||||
<WorkbenchHero
|
||||
@@ -309,6 +829,16 @@ export function PortalHomePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
|
||||
<QuickActionsPanel items={quickActions} />
|
||||
<TodoAggregatePanel items={todoAggregateItems} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr),minmax(0,0.85fr)]">
|
||||
<TeamStatusBoardPanel items={payload.teamStatusBoard} />
|
||||
<RecentActivitiesPanel items={payload.recentActivities} />
|
||||
</div>
|
||||
|
||||
{isManagerView ? (
|
||||
<>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(0,0.95fr)]">
|
||||
|
||||
在新工单中引用
屏蔽一个用户