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

712 行
23 KiB
TypeScript

import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchPortalHome, type PortalHomePayload } from "../api/portal";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, isManagerRole } from "../auth/permissions";
import { ErrorBoundary } from "../components/common/ErrorBoundary";
import { WelcomeDialog } from "../components/onboarding/WelcomeDialog";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import {
clearWelcomeDialogFlag,
shouldShowWelcomeDialog,
} from "../lib/onboardingGuide";
type RoleCapabilities = NonNullable<PortalHomePayload["roleCapabilities"]>;
type WorkItem = {
key: string;
title: string;
reason: string;
meta: string;
href: string;
actionLabel: string;
severity: "normal" | "warning" | "high";
icon: string;
priority: number;
count: number;
};
type EmptyAction = {
key: string;
label: string;
href: string;
variant?: "primary" | "secondary" | "ghost";
};
type QuickEntry = {
key: string;
label: string;
href: string;
actionLabel: string;
icon: string;
};
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 PROCUREMENT_PORTAL_PERMISSIONS = ["procurement.view_own", "procurement.view_all", "procurement.create", "procurement.submit"];
const TRAVEL_PORTAL_PERMISSIONS = ["travel.view_own", "travel.view_all", "travel.create", "travel.submit"];
const APPROVAL_PORTAL_PERMISSIONS = ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"];
const FINANCE_PORTAL_PERMISSIONS = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
const SYSTEM_PORTAL_PERMISSIONS = ["system.users", "system.settings", "system.audit.view", "notification.channel.manage", "payment.account.read", "payment.account.manage"];
const CONTRACT_PORTAL_PERMISSIONS = ["project.view_all", "project.manage", "system.settings"];
const PROJECT_PORTAL_PERMISSIONS = ["project.view_own", "project.view_all", "project.manage"];
const ASSET_PORTAL_PERMISSIONS = ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"];
const INVOICE_PORTAL_PERMISSIONS = ["invoice.manage"];
const SEVERITY_RANK = {
high: 3,
warning: 2,
normal: 1,
} as const;
function buildFallbackRoleCapabilities(permissions: string[], roleCodes: string[]): RoleCapabilities {
const hasAdminRole = roleCodes.includes("admin") || hasAnyPermission(permissions, ["*"]);
const hasBusinessApproverRole = roleCodes.some((roleCode) =>
["manager", "ceo", "finance", "finance_mgr", "hr"].includes(roleCode),
);
const hasFinanceRole = roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode));
return {
canSubmitApplications: hasAnyPermission(permissions, [
...LEAVE_PORTAL_PERMISSIONS,
...EXPENSE_PORTAL_PERMISSIONS,
...PROCUREMENT_PORTAL_PERMISSIONS,
...TRAVEL_PORTAL_PERMISSIONS,
]),
canApproveTasks: hasBusinessApproverRole && hasAnyPermission(permissions, APPROVAL_PORTAL_PERMISSIONS),
canUseFinance: hasFinanceRole && hasAnyPermission(permissions, FINANCE_PORTAL_PERMISSIONS),
canManageSystem: hasAdminRole || hasAnyPermission(permissions, SYSTEM_PORTAL_PERMISSIONS),
canViewTeam: hasBusinessApproverRole || hasAnyPermission(permissions, ["report.dashboard.view", "system.users", "project.manage"]),
isAdminOnly: hasAdminRole && !hasBusinessApproverRole,
};
}
function sortWorkItems(items: WorkItem[]) {
return [...items].sort((left, right) => {
const severityCompare = SEVERITY_RANK[right.severity] - SEVERITY_RANK[left.severity];
if (severityCompare !== 0) {
return severityCompare;
}
return right.priority - left.priority;
});
}
function uniqueWorkItems(items: WorkItem[]) {
const deduped = new Map<string, WorkItem>();
for (const item of items) {
if (!deduped.has(item.key)) {
deduped.set(item.key, item);
}
}
return sortWorkItems(Array.from(deduped.values()));
}
function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: RoleCapabilities) {
const items: WorkItem[] = [];
if (payload.pendingTasks.count > 0) {
items.push({
key: "workflow-pending",
title: `待我处理 ${payload.pendingTasks.count} 条`,
reason: "审批、确认或流程处理正在等你。",
meta: "流程待办",
href: payload.pendingTasks.href,
actionLabel: "去处理",
severity: "warning",
icon: "fact_check",
priority: 100,
count: payload.pendingTasks.count,
});
}
const openTodoCount = payload.todoSummary?.openCount ?? 0;
if (openTodoCount > 0) {
const highPriorityCount = payload.todoSummary?.highPriorityCount ?? 0;
items.push({
key: "todo-open",
title: `待办事项 ${openTodoCount} 条`,
reason: highPriorityCount > 0
? `其中 ${highPriorityCount} 条是高优先级,需要先确认。`
: "这些事项已分配给你,需要继续推进。",
meta: highPriorityCount > 0 ? "高优先级" : "个人待办",
href: payload.todoSummary?.href ?? "/todos",
actionLabel: "查看待办",
severity: highPriorityCount > 0 ? "high" : "normal",
icon: "check_circle",
priority: 90,
count: openTodoCount,
});
}
if (payload.weeklyReport?.status === "draft") {
items.push({
key: "weekly-draft",
title: "本周周报待填写",
reason: "需要补充本周进展、风险和下周计划。",
meta: "本周",
href: payload.weeklyReport.href,
actionLabel: "填写周报",
severity: payload.weeklyReport.needsHelp ? "high" : "warning",
icon: "article",
priority: 72,
count: 1,
});
}
if (roleCapabilities.canUseFinance && payload.financeSummary) {
if (payload.financeSummary.failedItemCount > 0) {
items.push({
key: "finance-failed-payment",
title: `异常付款 ${payload.financeSummary.failedItemCount} 项`,
reason: "付款失败或异常需要财务确认原因。",
meta: "财务异常",
href: payload.financeSummary.href,
actionLabel: "处理异常",
severity: "high",
icon: "priority_high",
priority: 96,
count: payload.financeSummary.failedItemCount,
});
}
if (payload.financeSummary.riskCount > 0) {
items.push({
key: "finance-risk-review",
title: `待复核风险 ${payload.financeSummary.riskCount} 项`,
reason: "付款或报销风险需要复核后再推进。",
meta: "风险复核",
href: payload.financeSummary.href,
actionLabel: "去复核",
severity: "high",
icon: "shield_person",
priority: 94,
count: payload.financeSummary.riskCount,
});
}
if (payload.financeSummary.pendingBatchCount > 0) {
items.push({
key: "finance-pending-batch",
title: `待付款批次 ${payload.financeSummary.pendingBatchCount} 个`,
reason: "需要继续确认、复核或提交付款。",
meta: "付款",
href: payload.financeSummary.href,
actionLabel: "处理付款",
severity: "warning",
icon: "payments",
priority: 86,
count: payload.financeSummary.pendingBatchCount,
});
}
}
if (payload.projectSummary?.highPriorityTaskCount) {
items.push({
key: "project-high-priority",
title: `高优先级项目任务 ${payload.projectSummary.highPriorityTaskCount} 项`,
reason: "项目动作需要今天确认下一步。",
meta: "项目",
href: payload.projectSummary.href,
actionLabel: "查看项目",
severity: "warning",
icon: "workspaces",
priority: 64,
count: payload.projectSummary.highPriorityTaskCount,
});
}
if (payload.projectSummary?.followUpDueTodayCount) {
items.push({
key: "project-follow-up",
title: `今日项目跟进 ${payload.projectSummary.followUpDueTodayCount} 项`,
reason: "客户、交付或验收动作今天需要跟进。",
meta: "今日",
href: payload.projectSummary.href,
actionLabel: "去跟进",
severity: "warning",
icon: "event_upcoming",
priority: 62,
count: payload.projectSummary.followUpDueTodayCount,
});
}
return uniqueWorkItems(items).slice(0, 8);
}
function buildProgressItems(payload: PortalHomePayload) {
const items: WorkItem[] = [];
if (payload.weeklyReport?.status === "submitted") {
items.push({
key: "weekly-submitted",
title: "本周周报已提交",
reason: "本周进展已记录,可进入周报页查看。",
meta: "已提交",
href: payload.weeklyReport.href,
actionLabel: "查看",
severity: "normal",
icon: "done_all",
priority: 30,
count: 1,
});
}
return uniqueWorkItems(items).slice(0, 3);
}
function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCapabilities) {
if (!roleCapabilities.canViewTeam && !roleCapabilities.canManageSystem) {
return [];
}
const items: WorkItem[] = [];
if ((payload.weeklySummary?.missingCount ?? 0) > 0) {
items.push({
key: "team-weekly-missing",
title: `${payload.weeklySummary?.missingCount ?? 0} 人未交周报`,
reason: "负责人需要提醒或确认是否有阻塞。",
meta: "团队",
href: payload.weeklySummary?.href ?? "/weekly-reports",
actionLabel: "查看周报",
severity: "warning",
icon: "article",
priority: 78,
count: payload.weeklySummary?.missingCount ?? 0,
});
}
if ((payload.weeklySummary?.needsHelpCount ?? 0) > 0) {
items.push({
key: "team-weekly-help",
title: `${payload.weeklySummary?.needsHelpCount ?? 0} 人标记需要协助`,
reason: "需要负责人确认问题和下一步支持。",
meta: "协助",
href: payload.weeklySummary?.href ?? "/weekly-reports",
actionLabel: "查看详情",
severity: "high",
icon: "support_agent",
priority: 82,
count: payload.weeklySummary?.needsHelpCount ?? 0,
});
}
if ((payload.teamPresence?.unspecified ?? 0) > 0) {
items.push({
key: "team-presence-missing",
title: `${payload.teamPresence?.unspecified ?? 0} 人未更新团队状态`,
reason: "今日在岗、外出或请假状态还不完整。",
meta: "状态",
href: payload.teamPresence?.href ?? "/team",
actionLabel: "查看团队",
severity: "warning",
icon: "groups",
priority: 58,
count: payload.teamPresence?.unspecified ?? 0,
});
}
if ((payload.projectSummary?.overdueMilestonesCount ?? 0) > 0) {
items.push({
key: "team-project-overdue",
title: `${payload.projectSummary?.overdueMilestonesCount ?? 0} 个里程碑已逾期`,
reason: "项目负责人需要调整计划或补充说明。",
meta: "项目",
href: payload.projectSummary?.href ?? "/projects",
actionLabel: "查看项目",
severity: "warning",
icon: "flag",
priority: 74,
count: payload.projectSummary?.overdueMilestonesCount ?? 0,
});
}
if ((payload.projectSummary?.openRiskCount ?? 0) > 0) {
items.push({
key: "team-project-risk",
title: `${payload.projectSummary?.openRiskCount ?? 0} 个项目风险未关闭`,
reason: "需要确认风险责任人、应对措施和截止时间。",
meta: "风险",
href: payload.projectSummary?.href ?? "/projects",
actionLabel: "处理风险",
severity: "high",
icon: "warning",
priority: 76,
count: payload.projectSummary?.openRiskCount ?? 0,
});
}
if ((payload.peopleLifecycle?.offboardingCount ?? 0) > 0 || (payload.peopleLifecycle?.assetReturnPendingCount ?? 0) > 0) {
items.push({
key: "team-offboarding-assets",
title: "离职和资产交接待收口",
reason: `离职 ${payload.peopleLifecycle?.offboardingCount ?? 0} 条,资产 ${payload.peopleLifecycle?.assetReturnPendingCount ?? 0} 项。`,
meta: "交接",
href: payload.peopleLifecycle?.href ?? "/people",
actionLabel: "查看人员",
severity: "warning",
icon: "inventory_2",
priority: 60,
count: (payload.peopleLifecycle?.offboardingCount ?? 0) + (payload.peopleLifecycle?.assetReturnPendingCount ?? 0),
});
}
return uniqueWorkItems(items).slice(0, 3);
}
function buildManagementReminders(
payload: PortalHomePayload,
roleCapabilities: RoleCapabilities,
canSetupWorkspace: boolean,
) {
if (!roleCapabilities.canManageSystem) {
return [];
}
const items: WorkItem[] = [];
if (!payload.workspace.persisted && canSetupWorkspace) {
items.push({
key: "workspace-setup",
title: "工作区设置未完成",
reason: "需要确认基础入口、通知策略和可见模块。",
meta: "配置",
href: "/system/workspace",
actionLabel: "去设置",
severity: "warning",
icon: "settings",
priority: 88,
count: 1,
});
}
const inactiveLoginCount = payload.adoptionSummary
? Math.max(0, payload.adoptionSummary.activeEmployeeCount - payload.adoptionSummary.login7dCount)
: 0;
if (inactiveLoginCount > 0) {
items.push({
key: "trial-login-missing",
title: `近 7 天 ${inactiveLoginCount} 人未登录`,
reason: "试运行覆盖率不足,需要确认账号或提醒到人。",
meta: "试运行",
href: "/system/workspace",
actionLabel: "查看配置",
severity: "warning",
icon: "manage_accounts",
priority: 52,
count: inactiveLoginCount,
});
}
return uniqueWorkItems(items).slice(0, 3);
}
function buildEmptyActions(permissions: string[], roleCapabilities: RoleCapabilities) {
const actions: EmptyAction[] = [];
if (!roleCapabilities.isAdminOnly && hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) {
actions.push({ key: "new-expense", label: "新建报销", href: "/reimbursements", variant: "primary" });
actions.push({ key: "my-progress", label: "查看进度", href: "/reimbursements", variant: "secondary" });
}
if (roleCapabilities.canManageSystem) {
actions.push({ key: "manage-settings", label: "管理设置", href: "/system/workspace", variant: actions.length ? "ghost" : "secondary" });
}
return actions.slice(0, roleCapabilities.canManageSystem && actions.length > 2 ? 3 : 2);
}
function buildQuickEntries(permissions: string[], roleCapabilities: RoleCapabilities) {
const entries: QuickEntry[] = [];
if (!roleCapabilities.isAdminOnly && hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) {
entries.push({
key: "reimbursements",
label: "报销",
href: "/reimbursements",
actionLabel: hasAnyPermission(permissions, ["expense.create", "expense.proxy_manage"]) ? "新建报销" : "查看报销",
icon: "request_quote",
});
}
if (hasAnyPermission(permissions, PROCUREMENT_PORTAL_PERMISSIONS)) {
entries.push({
key: "purchase",
label: "采购",
href: "/purchase-requests",
actionLabel: hasAnyPermission(permissions, ["procurement.create", "procurement.submit"]) ? "新建采购" : "查看采购",
icon: "shopping_cart",
});
}
if (hasAnyPermission(permissions, CONTRACT_PORTAL_PERMISSIONS)) {
entries.push({
key: "contracts",
label: "合同",
href: "/contracts-registry",
actionLabel: "查看合同",
icon: "description",
});
}
if (hasAnyPermission(permissions, PROJECT_PORTAL_PERMISSIONS)) {
entries.push({
key: "projects",
label: "项目",
href: "/projects",
actionLabel: "查看项目",
icon: "workspaces",
});
}
if (hasAnyPermission(permissions, ASSET_PORTAL_PERMISSIONS)) {
entries.push({
key: "assets",
label: "资产",
href: "/assets",
actionLabel: "查看资产",
icon: "inventory_2",
});
}
if (roleCapabilities.canUseFinance) {
entries.push({
key: "payments",
label: "付款",
href: "/payment-batches",
actionLabel: "处理付款",
icon: "payments",
});
}
if (hasAnyPermission(permissions, INVOICE_PORTAL_PERMISSIONS)) {
entries.push({
key: "invoices",
label: "票据",
href: "/invoice-pool",
actionLabel: "查看票据",
icon: "receipt_long",
});
}
return entries.slice(0, 6);
}
function sumWorkItemCount(items: WorkItem[]) {
return items.reduce((total, item) => total + Math.max(1, item.count), 0);
}
function SummaryStrip({
userName,
actionCount,
progressCount,
blockerCount,
reminderCount,
}: {
userName: string;
actionCount: number;
progressCount: number;
blockerCount: number;
reminderCount: number;
}) {
const attentionCount = blockerCount + reminderCount;
const summaryParts = [`待处理 ${actionCount}`];
if (progressCount > 0) summaryParts.push(`进度 ${progressCount}`);
if (attentionCount > 0) summaryParts.push(`提醒 ${attentionCount}`);
const summaryText = summaryParts.join(" · ");
return (
<section className="portal-work-summary" data-testid="portal-work-summary">
<div className="min-w-0">
<div className="portal-work-summary__title">{summaryText}</div>
<div className="portal-work-summary__meta">{userName} · 当前工作区</div>
</div>
</section>
);
}
function WorkItemRow({ item }: { item: WorkItem }) {
const severityClass = item.severity === "high"
? "portal-task-row portal-task-row--high"
: item.severity === "warning"
? "portal-task-row portal-task-row--warning"
: "portal-task-row";
return (
<Link to={item.href} className={severityClass}>
<span className="portal-task-row__icon" aria-hidden="true">
<span className="material-symbols-outlined text-[18px]">{item.icon}</span>
</span>
<span className="min-w-0 flex-1">
<span className="portal-task-row__title">{item.title}</span>
</span>
<span className="portal-task-row__meta">{item.meta}</span>
<span className="portal-task-row__action">{item.actionLabel}</span>
</Link>
);
}
function WorkItemList({ items }: { items: WorkItem[] }) {
return (
<div className="portal-task-list">
{items.map((item) => (
<WorkItemRow key={item.key} item={item} />
))}
</div>
);
}
function QuickEntryPanel({ entries }: { entries: QuickEntry[] }) {
if (!entries.length) {
return null;
}
return (
<SectionPanel title="常用入口">
<div className="portal-quick-entry-grid" data-testid="portal-quick-entries">
{entries.map((entry) => (
<Link className="portal-quick-entry" key={entry.key} to={entry.href}>
<span className="portal-quick-entry__icon" aria-hidden="true">
<span className="material-symbols-outlined text-[18px]">{entry.icon}</span>
</span>
<span className="min-w-0">
<span className="portal-quick-entry__title">{entry.label}</span>
</span>
</Link>
))}
</div>
</SectionPanel>
);
}
function TodayActionPanel({
items,
emptyActions,
}: {
items: WorkItem[];
emptyActions: EmptyAction[];
}) {
return (
<SectionPanel title="今日待处理" className="portal-panel-fill">
{items.length ? (
<WorkItemList items={items} />
) : (
<div className="portal-empty-start">
<div>
<div className="portal-empty-start__title">暂无待处理</div>
</div>
{emptyActions.length ? (
<div className="portal-inline-actions">
{emptyActions.map((action) => (
<Button
key={action.key}
asChild
type="button"
variant={action.variant === "primary" ? "default" : action.variant ?? "secondary"}
>
<Link to={action.href}>{action.label}</Link>
</Button>
))}
</div>
) : null}
</div>
)}
</SectionPanel>
);
}
function ConditionalWorkPanel({
title,
items,
}: {
title: string;
items: WorkItem[];
}) {
if (!items.length) {
return null;
}
return (
<SectionPanel title={title}>
<WorkItemList items={items} />
</SectionPanel>
);
}
export function PortalHomePage() {
const { me, permissions } = useAuth();
const [showWelcome, setShowWelcome] = useState(false);
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
useEffect(() => {
setShowWelcome(shouldShowWelcomeDialog());
}, []);
if (portalQuery.isLoading) {
return <div className="card-std p-5 text-sm text-zinc-500">加载工作台中...</div>;
}
if (portalQuery.isError || !portalQuery.data) {
const message = isApiError(portalQuery.error)
? `${portalQuery.error.code}: ${mapApiErrorMessage(portalQuery.error.code, portalQuery.error.details, portalQuery.error.message)}`
: "工作台加载失败";
return <div className="card-std p-5 text-sm text-red-600">{message}</div>;
}
const payload = portalQuery.data;
const roleCapabilities = payload.roleCapabilities ?? buildFallbackRoleCapabilities(permissions, me?.roleCodes ?? []);
const isManagerView =
roleCapabilities.canViewTeam ||
roleCapabilities.canApproveTasks ||
roleCapabilities.canUseFinance ||
roleCapabilities.canManageSystem ||
isManagerRole(permissions);
const todayActionItems = buildTodayActionItems(payload, roleCapabilities);
const quickEntries = buildQuickEntries(permissions, roleCapabilities);
const progressItems = buildProgressItems(payload);
const teamBlockers = isManagerView ? buildTeamBlockers(payload, roleCapabilities) : [];
const managementReminders = buildManagementReminders(payload, roleCapabilities, canSetupWorkspace);
const emptyActions = buildEmptyActions(permissions, roleCapabilities);
const actionCount = sumWorkItemCount(todayActionItems);
const progressCount = sumWorkItemCount(progressItems);
const blockerCount = sumWorkItemCount(teamBlockers);
const reminderCount = sumWorkItemCount(managementReminders);
function handleCloseWelcome() {
clearWelcomeDialogFlag();
setShowWelcome(false);
}
return (
<PageScaffold>
<ErrorBoundary fallback={null}>
<WelcomeDialog open={showWelcome} onClose={handleCloseWelcome} />
</ErrorBoundary>
<SummaryStrip
userName={payload.greeting.name}
actionCount={actionCount}
progressCount={progressCount}
blockerCount={blockerCount}
reminderCount={reminderCount}
/>
<QuickEntryPanel entries={quickEntries} />
<TodayActionPanel items={todayActionItems} emptyActions={emptyActions} />
<div className="portal-task-secondary-grid">
<ConditionalWorkPanel title="我的进度" items={progressItems} />
<ConditionalWorkPanel title="团队卡点" items={teamBlockers} />
<ConditionalWorkPanel title="管理提醒" items={managementReminders} />
</div>
</PageScaffold>
);
}