712 行
23 KiB
TypeScript
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>
|
|
);
|
|
}
|