const { AppError } = require("../utils/AppError"); const { buildTeamPresenceSummary, resolvePresenceForEmployee, } = require("../services/presenceHistory"); const { sendData } = require("../utils/reply"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { getWorkspaceSetting } = require("../services/workspaceSettings"); const { currentDateString, parseDateOnlyString } = require("../utils/date"); const PORTAL_RECENT_ACTIVITY_LIMIT = 15; const PORTAL_RECENT_ACTIVITY_SOURCE_TAKE = 24; const BUSINESS_APPROVE_PERMISSIONS = [ "expense.approve", "leave.approve", "procurement.approve", "travel.approve", ]; const BUSINESS_SUBMIT_PERMISSIONS = [ "expense.create", "expense.submit", "leave.create", "leave.submit", "procurement.create", "procurement.submit", "travel.create", "travel.submit", ]; const SYSTEM_MANAGE_PERMISSIONS = [ "system.users", "system.settings", "system.audit.view", "notification.channel.manage", ]; const FINANCE_ROLE_CODES = new Set(["finance", "finance_mgr"]); const BUSINESS_APPROVER_ROLE_CODES = new Set(["manager", "ceo", "finance", "finance_mgr", "hr"]); async function buildTaskVisibilityFilters(tx, request) { const roleFilters = (request.employee.roleCodes ?? []).length ? [{ assignee_role: { in: request.employee.roleCodes } }] : []; return [{ assignee_id: request.employee.id }, ...roleFilters]; } function hasAnyPermission(fastify, employee, permissionCodes) { return permissionCodes.some((permissionCode) => fastify.hasPermission(employee, permissionCode)); } function buildRoleCapabilities(fastify, employee) { const roleCodes = employee.roleCodes ?? []; const isAdmin = roleCodes.includes("admin") || fastify.hasPermission(employee, "*"); const hasBusinessApproverRole = roleCodes.some((roleCode) => BUSINESS_APPROVER_ROLE_CODES.has(roleCode)); const canUseFinance = roleCodes.some((roleCode) => FINANCE_ROLE_CODES.has(roleCode)) && hasAnyPermission(fastify, employee, ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"]); return { canSubmitApplications: hasAnyPermission(fastify, employee, BUSINESS_SUBMIT_PERMISSIONS), canApproveTasks: hasBusinessApproverRole && hasAnyPermission(fastify, employee, BUSINESS_APPROVE_PERMISSIONS), canUseFinance, canManageSystem: isAdmin || hasAnyPermission(fastify, employee, SYSTEM_MANAGE_PERMISSIONS), canViewTeam: hasBusinessApproverRole || hasAnyPermission(fastify, employee, ["report.dashboard.view", "system.users", "project.manage"]), isAdminOnly: isAdmin && !hasBusinessApproverRole, }; } async function ensureFeatureEnabled(fastify, request) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.portalHome, departmentId: request.employee.department_id ?? null, strictDepartment: false, }); if (!gate.enabled) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { featureFlag: "portal_home_v1_disabled", reason: gate.reason }, }); } } function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); } async function evaluateOptionalFlag(fastify, request, flagKey) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey, departmentId: request.employee.department_id ?? null, strictDepartment: false, }); return gate.enabled; } function canReadAllProjects(fastify, employee) { return fastify.hasPermission(employee, "project.view_all") || fastify.hasPermission(employee, "project.manage") || fastify.hasPermission(employee, "*"); } function projectScopeWhere(fastify, employee) { if (canReadAllProjects(fastify, employee)) { return undefined; } return { OR: [ { owner_employee_id: employee.id }, { manager_employee_id: employee.id }, { members: { some: { employee_id: employee.id, left_at: null } } }, ], }; } function resolveWeekStart(rawDateString) { const date = parseDateOnlyString(rawDateString); const day = date.getUTCDay() || 7; date.setUTCDate(date.getUTCDate() - (day - 1)); return date.toISOString().slice(0, 10); } function formatPresenceStatus(item) { if (!item) return "未更新"; if (item.status === "office") return "在岗"; if (item.status === "remote") return "居家"; if (item.status === "out") return "外出"; if (item.status === "travel") return "出差"; if (item.status === "leave") return "请假"; return item.status; } function buildQuickLinks({ request, featureEnabled }) { const items = []; items.push({ key: "team", title: "团队状态", href: "/team" }); if (request.employee.permissionCodes.includes("leave.view_own") || request.employee.permissionCodes.includes("leave.view_all")) { items.push({ key: "leave", title: "请假", href: "/leave-requests" }); } if (request.employee.permissionCodes.includes("expense.view_own") || request.employee.permissionCodes.includes("expense.view_all")) { items.push({ key: "expense", title: "报销", href: "/reimbursements" }); } items.push({ key: "assets", title: "资产", href: "/assets" }); if (featureEnabled.weeklyReport) { items.push({ key: "weekly", title: "周报", href: "/weekly-reports" }); } if (featureEnabled.todoBoard) { items.push({ key: "todo", title: "待办", href: "/todos" }); } if (featureEnabled.projectDomain) { items.push({ key: "projects", title: "项目", href: "/projects" }); } if (request.employee.permissionCodes.includes("system.users") || request.employee.permissionCodes.includes("system.settings")) { items.push({ key: "people", title: "人员管理", href: "/people" }); items.push({ key: "workspace", title: "工作区设置", href: "/system/workspace" }); } return items.slice(0, 8); } function buildAvatarUrl(employeeId, avatarPath) { return avatarPath ? `/api/avatars/${employeeId}` : null; } function normalizeWorkflowActivityType(entityType, action) { if (entityType === "reimbursement" && action === "start") return "reimbursement.submit"; if (entityType === "reimbursement" && action === "approve") return "reimbursement.approve"; if (entityType === "reimbursement" && action === "reject") return "reimbursement.reject"; if (entityType === "leave" && action === "start") return "leave.submit"; if (entityType === "leave" && action === "approve") return "leave.approve"; if (entityType === "leave" && action === "reject") return "leave.reject"; return null; } function buildActivityHref(type, entityId) { if (type.startsWith("reimbursement.")) { return entityId ? `/reimbursements/${entityId}` : "/reimbursements"; } if (type.startsWith("leave.")) { return "/leave-requests"; } if (type.startsWith("attendance.")) { return "/attendance"; } if (type.startsWith("asset.")) { return "/assets"; } return "/portal"; } function buildWorkflowActivitySummary(type, actorName) { if (type === "reimbursement.submit") return `${actorName} 提交了报销单`; if (type === "reimbursement.approve") return `${actorName} 通过了报销单`; if (type === "reimbursement.reject") return `${actorName} 驳回了报销单`; if (type === "leave.submit") return `${actorName} 提交了请假单`; if (type === "leave.approve") return `${actorName} 通过了请假单`; if (type === "leave.reject") return `${actorName} 驳回了请假单`; return `${actorName} 更新了流程记录`; } function buildAuditActivitySummary(type, actorName, extra) { const assetNo = extra?.assetNo ? ` ${extra.assetNo}` : ""; if (type === "asset.assign") return `${actorName} 分配了资产${assetNo}`; if (type === "asset.return") return `${actorName} 完成了资产归还${assetNo}`; if (type === "attendance.supplement") return `${actorName} 补录了考勤`; return `${actorName} 更新了业务记录`; } function buildAttendanceActivitySummary(type, actorName) { if (type === "attendance.clock_in") return `${actorName} 完成了上班打卡`; if (type === "attendance.clock_out") return `${actorName} 完成了下班打卡`; return `${actorName} 更新了考勤`; } function sortActivitiesByCreatedAtDesc(left, right) { return new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(); } async function buildRecentActivities(prisma) { const [workflowRows, auditRows, attendanceRows] = await Promise.all([ prisma.workflowHistory.findMany({ where: { action: { in: ["start", "approve", "reject"] }, workflow_instance: { is: { entity_type: { in: ["reimbursement", "leave"] }, }, }, }, orderBy: { created_at: "desc" }, take: PORTAL_RECENT_ACTIVITY_SOURCE_TAKE, select: { id: true, action: true, created_at: true, actor: { select: { name: true } }, workflow_instance: { select: { entity_type: true, entity_id: true, }, }, }, }), prisma.auditLog.findMany({ where: { action: { in: ["ASSET_ASSIGNED", "ASSET_RETURNED", "ATTENDANCE_ADMIN_BACKFILLED"] }, }, orderBy: { created_at: "desc" }, take: PORTAL_RECENT_ACTIVITY_SOURCE_TAKE, select: { id: true, action: true, target_id: true, extra: true, created_at: true, actor: { select: { name: true } }, }, }), prisma.attendanceRecord.findMany({ where: { OR: [ { clock_in_at: { not: null } }, { clock_out_at: { not: null } }, ], }, orderBy: { updated_at: "desc" }, take: PORTAL_RECENT_ACTIVITY_SOURCE_TAKE, select: { id: true, clock_in_at: true, clock_out_at: true, employee_id: true, employee: { select: { name: true } }, }, }), ]); const workflowActivities = workflowRows .map((row) => { const type = normalizeWorkflowActivityType( row.workflow_instance?.entity_type ?? null, row.action, ); if (!type || !row.created_at) { return null; } const actorName = row.actor?.name ?? "系统"; return { id: `workflow-${row.id}`, type, actorName, summary: buildWorkflowActivitySummary(type, actorName), createdAt: row.created_at.toISOString(), href: buildActivityHref(type, row.workflow_instance?.entity_id ?? null), }; }) .filter(Boolean); const auditActionMap = { ASSET_ASSIGNED: "asset.assign", ASSET_RETURNED: "asset.return", ATTENDANCE_ADMIN_BACKFILLED: "attendance.supplement", }; const auditActivities = auditRows .map((row) => { const type = auditActionMap[row.action]; if (!type || !row.created_at) { return null; } const actorName = row.actor?.name ?? "系统"; const extra = row.extra && typeof row.extra === "object" ? row.extra : {}; return { id: `audit-${row.id}`, type, actorName, summary: buildAuditActivitySummary(type, actorName, extra), createdAt: row.created_at.toISOString(), href: buildActivityHref(type, row.target_id ?? null), }; }) .filter(Boolean); const attendanceActivities = attendanceRows.flatMap((row) => { const actorName = row.employee?.name ?? row.employee_id; const items = []; if (row.clock_in_at) { items.push({ id: `attendance-clock-in-${row.id}`, type: "attendance.clock_in", actorName, summary: buildAttendanceActivitySummary("attendance.clock_in", actorName), createdAt: row.clock_in_at.toISOString(), href: buildActivityHref("attendance.clock_in"), }); } if (row.clock_out_at) { items.push({ id: `attendance-clock-out-${row.id}`, type: "attendance.clock_out", actorName, summary: buildAttendanceActivitySummary("attendance.clock_out", actorName), createdAt: row.clock_out_at.toISOString(), href: buildActivityHref("attendance.clock_out"), }); } return items; }); return [...workflowActivities, ...auditActivities, ...attendanceActivities] .sort(sortActivitiesByCreatedAtDesc) .slice(0, PORTAL_RECENT_ACTIVITY_LIMIT); } async function buildTeamStatusBoard(prisma, dateString) { const employees = await prisma.employee.findMany({ where: { status: "active" }, orderBy: [{ department_id: "asc" }, { employee_no: "asc" }], select: { id: true, name: true, avatar_path: true, legal_entity_id: true, }, }); if (!employees.length) { return []; } const employeeIds = employees.map((item) => item.id); const [presenceList, attendanceRows, leaveRows] = await Promise.all([ Promise.all(employees.map((employee) => resolvePresenceForEmployee(prisma, employee.id, dateString))), prisma.attendanceRecord.findMany({ where: { employee_id: { in: employeeIds }, work_date: parseDateOnlyString(dateString), }, select: { employee_id: true, clock_in_at: true, clock_out_at: true, }, }), prisma.leaveRequest.findMany({ where: { owner_id: { in: employeeIds }, status: "approved", start_date: { lte: parseDateOnlyString(dateString) }, end_date: { gte: parseDateOnlyString(dateString) }, }, orderBy: { created_at: "desc" }, select: { owner_id: true, leave_type: { select: { name: true }, }, }, }), ]); const attendanceByEmployeeId = new Map( attendanceRows.map((row) => [row.employee_id, row]), ); const leaveByEmployeeId = new Map(); for (const row of leaveRows) { if (!leaveByEmployeeId.has(row.owner_id)) { leaveByEmployeeId.set(row.owner_id, row); } } return employees.map((employee, index) => { const attendance = attendanceByEmployeeId.get(employee.id) ?? null; const leave = leaveByEmployeeId.get(employee.id) ?? null; const presence = presenceList[index] ?? null; return { employeeId: employee.id, name: employee.name, avatarUrl: buildAvatarUrl(employee.id, employee.avatar_path), presenceStatus: presence?.status ?? null, attendanceClockIn: attendance?.clock_in_at?.toISOString?.() ?? null, attendanceClockOut: attendance?.clock_out_at?.toISOString?.() ?? null, onLeave: Boolean(leave), leaveType: leave?.leave_type?.name ?? null, }; }); } function buildAdoptionHints({ activeEmployeeCount, login7dCount, weeklySummary, teamPresenceSummary, offboardingCount, assetReturnPendingCount }) { const hints = []; if (login7dCount < activeEmployeeCount) { hints.push(`近 7 天仍有 ${Math.max(0, activeEmployeeCount - login7dCount)} 人未登录系统`); } if ((weeklySummary?.missingCount ?? 0) > 0) { hints.push(`本周仍有 ${weeklySummary.missingCount} 人未提交周报`); } if ((teamPresenceSummary?.unspecified ?? 0) > 0) { hints.push(`今日仍有 ${teamPresenceSummary.unspecified} 人未更新团队状态`); } if ((offboardingCount ?? 0) > 0 || (assetReturnPendingCount ?? 0) > 0) { hints.push(`仍有 ${offboardingCount ?? 0} 条离职流程和 ${assetReturnPendingCount ?? 0} 项资产交接待收口`); } if (!hints.length) { hints.push("近 7 天登录、周报、团队状态与离职交接均满足试点观测口径"); } return hints; } async function portalRoutes(fastify) { fastify.get("/portal/home", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const workspace = await getWorkspaceSetting(fastify.prisma); const today = currentDateString(fastify.config.appTimezone); const currentWeekStart = resolveWeekStart(today); const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request); const roleCapabilities = buildRoleCapabilities(fastify, request.employee); const isManagerView = fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "report.dashboard.view") || fastify.hasPermission(request.employee, "*"); const featureEnabled = { announcements: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.announcements), presenceBoard: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.presenceBoard), weeklyReport: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.weeklyReport), employeeLifecycle: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.employeeLifecycle), todoBoard: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.todoBoard), paymentBatch: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.paymentBatch), projectDomain: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.projectDomain), projectCommunicationLog: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog), projectClosureChecklist: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist), }; const [ pendingTaskCount, reimbursementsCount, leaveCount, assetCount, riskCount, announcementRows, todoRows, myWeeklyReport, onboardingCount, offboardingCount, assetReturnPendingCount, activeEmployeeCount, login7dCount, myProjectCount, overdueProjectMilestoneCount, highPriorityProjectTaskCount, openProjectRiskCount, projectFollowUpDueTodayCount, closurePendingProjectCount, pendingFinanceBatchCount, failedFinanceItemCount, recentActivities, teamStatusBoard, ] = await Promise.all([ fastify.prisma.workflowTask.count({ where: { status: "pending", OR: assigneeFilters } }), fastify.prisma.reimbursementForm.count({ where: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }), fastify.prisma.leaveRequest.count({ where: { owner_id: request.employee.id } }), fastify.prisma.assetCard.count({ where: { assignee_employee_id: request.employee.id } }), roleCapabilities.canUseFinance ? fastify.prisma.entityRiskFlag.count({ where: { status: "open" } }) : Promise.resolve(0), featureEnabled.announcements ? fastify.prisma.portalAnnouncement.findMany({ where: { enabled: true, OR: [{ starts_at: null }, { starts_at: { lte: new Date() } }], AND: [{ OR: [{ ends_at: null }, { ends_at: { gte: new Date() } }] }], }, orderBy: [{ priority: "asc" }, { created_at: "desc" }], take: 6, }) : Promise.resolve([]), featureEnabled.todoBoard ? fastify.prisma.todoItem.findMany({ where: { assignee_employee_id: request.employee.id, status: { in: ["todo", "doing"] } }, orderBy: [{ priority: "desc" }, { due_date: "asc" }, { updated_at: "desc" }], take: 10, }) : Promise.resolve([]), featureEnabled.weeklyReport ? fastify.prisma.weeklyReport.findUnique({ where: { employee_id_week_start: { employee_id: request.employee.id, week_start: parseDateOnlyString(currentWeekStart), }, }, }) : Promise.resolve(null), featureEnabled.employeeLifecycle && isManagerView ? fastify.prisma.employee.count({ where: { employment_status: "onboarding", status: "active" } }) : Promise.resolve(0), featureEnabled.employeeLifecycle && isManagerView ? fastify.prisma.employee.count({ where: { employment_status: "offboarding" } }) : Promise.resolve(0), featureEnabled.employeeLifecycle && isManagerView ? fastify.prisma.assetCard.count({ where: { assignee: { is: { employment_status: "offboarding" } } } }) : Promise.resolve(0), isManagerView ? fastify.prisma.employee.count({ where: { status: "active" } }) : Promise.resolve(0), isManagerView ? fastify.prisma.authSession.findMany({ where: { revoked_at: null, expires_at: { gt: new Date() }, last_seen_at: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, employee: { status: "active" }, }, select: { employee_id: true }, distinct: ["employee_id"], }).then((rows) => rows.length) : Promise.resolve(0), featureEnabled.projectDomain ? fastify.prisma.project.count({ where: projectScopeWhere(fastify, request.employee) ?? {}, }) : Promise.resolve(0), featureEnabled.projectDomain ? fastify.prisma.projectMilestone.count({ where: { status: { in: ["pending", "in_progress", "delayed"] }, due_date: { lt: new Date() }, project: projectScopeWhere(fastify, request.employee), }, }) : Promise.resolve(0), featureEnabled.projectDomain ? fastify.prisma.projectTask.count({ where: { status: { in: ["todo", "doing", "blocked"] }, priority: { in: ["high", "urgent"] }, project: projectScopeWhere(fastify, request.employee), }, }) : Promise.resolve(0), featureEnabled.projectDomain ? fastify.prisma.projectRisk.count({ where: { status: { not: "closed" }, project: projectScopeWhere(fastify, request.employee), }, }) : Promise.resolve(0), featureEnabled.projectDomain && featureEnabled.projectCommunicationLog ? fastify.prisma.projectCommunicationLog.count({ where: { next_follow_up_date: parseDateOnlyString(today), project: projectScopeWhere(fastify, request.employee), }, }) : Promise.resolve(0), featureEnabled.projectDomain && featureEnabled.projectClosureChecklist ? fastify.prisma.projectClosureChecklistItem.findMany({ where: { status: "pending", project: projectScopeWhere(fastify, request.employee), }, distinct: ["project_id"], select: { project_id: true }, }).then((items) => items.length) : Promise.resolve(0), roleCapabilities.canUseFinance && featureEnabled.paymentBatch ? fastify.prisma.paymentBatch.count({ where: { status: { in: ["draft", "reviewed", "failed"] } }, }) : Promise.resolve(0), roleCapabilities.canUseFinance && featureEnabled.paymentBatch ? fastify.prisma.paymentBatchItem.count({ where: { status: "failed" } }) : Promise.resolve(0), buildRecentActivities(fastify.prisma), buildTeamStatusBoard(fastify.prisma, today), ]); const todayPresence = featureEnabled.presenceBoard ? await resolvePresenceForEmployee(fastify.prisma, request.employee.id, today) : null; const teamPresenceSummary = featureEnabled.presenceBoard && isManagerView ? await buildTeamPresenceSummary(fastify.prisma, today) : null; const teamWeeklySummary = featureEnabled.weeklyReport && isManagerView ? await (async () => { const [submittedCount, needsHelpCount] = await Promise.all([ fastify.prisma.weeklyReport.count({ where: { week_start: parseDateOnlyString(currentWeekStart), status: "submitted" } }), fastify.prisma.weeklyReport.count({ where: { week_start: parseDateOnlyString(currentWeekStart), needs_help: true } }), ]); return { weekStart: currentWeekStart, submittedCount, missingCount: Math.max(0, activeEmployeeCount - submittedCount), needsHelpCount, }; })() : null; const cards = []; if (isManagerView) { cards.push({ key: "team_presence", title: "今日团队状态", value: teamPresenceSummary ? `${teamPresenceSummary.office}/${teamPresenceSummary.total} 在岗` : "-", href: "/team", severity: teamPresenceSummary && (teamPresenceSummary.leave > 0 || teamPresenceSummary.unspecified > 0) ? "warning" : "normal", }); cards.push({ key: "weekly_missing", title: "未提交周报", value: teamWeeklySummary?.missingCount ?? 0, href: "/weekly-reports", severity: (teamWeeklySummary?.missingCount ?? 0) > 0 ? "warning" : "normal", }); cards.push({ key: "weekly_help", title: "需协助员工", value: teamWeeklySummary?.needsHelpCount ?? 0, href: "/weekly-reports", severity: (teamWeeklySummary?.needsHelpCount ?? 0) > 0 ? "high" : "normal", }); cards.push({ key: "people_lifecycle", title: "入/离职待办", value: (onboardingCount ?? 0) + (offboardingCount ?? 0), href: "/people", severity: (offboardingCount ?? 0) > 0 ? "warning" : "normal", }); cards.push({ key: "asset_return_pending", title: "资产待归还", value: assetReturnPendingCount ?? 0, href: "/people", severity: (assetReturnPendingCount ?? 0) > 0 ? "warning" : "normal", }); if (roleCapabilities.canApproveTasks) { cards.push({ key: "pending_approvals", title: "审批待办", value: pendingTaskCount, href: "/workflow/tasks", severity: pendingTaskCount > 0 ? "warning" : "normal", }); } if (featureEnabled.projectDomain) { cards.push({ key: "project_milestones", title: "逾期里程碑", value: overdueProjectMilestoneCount, href: "/projects", severity: overdueProjectMilestoneCount > 0 ? "warning" : "normal" }); cards.push({ key: "project_risks", title: "项目风险", value: openProjectRiskCount, href: "/projects", severity: openProjectRiskCount > 0 ? "high" : "normal" }); } } else { cards.push({ key: "my_todos", title: "我的待办", value: featureEnabled.todoBoard ? todoRows.length : 0, href: "/todos", severity: todoRows.some((item) => item.priority === "high") ? "warning" : "normal", }); cards.push({ key: "today_presence", title: "今日状态", value: featureEnabled.presenceBoard ? formatPresenceStatus(todayPresence) : "未启用", href: "/team", severity: todayPresence?.status === "leave" ? "warning" : "normal", }); cards.push({ key: "my_leave", title: "我的请假", value: leaveCount, href: "/leave-requests", severity: "normal" }); cards.push({ key: "my_reimbursements", title: "我的报销", value: reimbursementsCount, href: "/reimbursements", severity: "normal" }); cards.push({ key: "my_assets", title: "我的资产", value: assetCount, href: "/assets", severity: "normal" }); cards.push({ key: "my_weekly_report", title: "本周周报", value: featureEnabled.weeklyReport ? (myWeeklyReport?.status === "submitted" ? "已提交" : "待填写") : "未启用", href: "/weekly-reports", severity: myWeeklyReport?.status === "submitted" ? "normal" : "warning", }); if (featureEnabled.projectDomain) { cards.push({ key: "my_projects", title: "我的项目", value: myProjectCount, href: "/projects", severity: highPriorityProjectTaskCount > 0 ? "warning" : "normal" }); } } if (featureEnabled.paymentBatch && roleCapabilities.canUseFinance && riskCount > 0) { cards.push({ key: "risk_flags", title: "待复核风险", value: riskCount, href: "/ap-operations", severity: "high" }); } const adoptionSummary = isManagerView ? (() => { const presenceUpdatedCount = teamPresenceSummary ? teamPresenceSummary.total - teamPresenceSummary.unspecified : 0; const weeklySubmittedCount = teamWeeklySummary?.submittedCount ?? 0; return { activeEmployeeCount, login7dCount, weeklySubmittedCount, weeklyMissingCount: teamWeeklySummary?.missingCount ?? 0, presenceUpdatedCount, presenceMissingCount: teamPresenceSummary?.unspecified ?? 0, announcementCount: announcementRows.length, login7dRate: activeEmployeeCount > 0 ? Number(((login7dCount / activeEmployeeCount) * 100).toFixed(1)) : 0, weeklySubmissionRate: activeEmployeeCount > 0 ? Number(((weeklySubmittedCount / activeEmployeeCount) * 100).toFixed(1)) : 0, presenceCoverageRate: activeEmployeeCount > 0 ? Number(((presenceUpdatedCount / activeEmployeeCount) * 100).toFixed(1)) : 0, hints: buildAdoptionHints({ activeEmployeeCount, login7dCount, weeklySummary: teamWeeklySummary, teamPresenceSummary, offboardingCount, assetReturnPendingCount, }), }; })() : null; return sendData(reply, request, { workspace, greeting: { name: request.employee.name || request.employee.id }, cards, pendingTasks: { count: pendingTaskCount, href: "/workflow/tasks" }, myApplications: [ { domain: "reimbursement", count: reimbursementsCount, href: "/reimbursements" }, { domain: "leave", count: leaveCount, href: "/leave-requests" }, ], riskSummary: riskCount > 0 ? { openCount: riskCount, href: "/ap-operations" } : null, announcements: announcementRows.map((item) => ({ id: item.id, title: item.title, summary: item.summary, href: item.link_url ?? null, })), quickLinks: buildQuickLinks({ request, featureEnabled }), todayPresence: featureEnabled.presenceBoard ? { date: today, status: todayPresence?.status ?? null, label: formatPresenceStatus(todayPresence), note: todayPresence?.note ?? null, href: "/team" } : null, teamPresence: teamPresenceSummary ? { date: today, href: "/team", ...teamPresenceSummary } : null, weeklyReport: featureEnabled.weeklyReport ? { weekStart: currentWeekStart, status: myWeeklyReport?.status ?? "draft", needsHelp: myWeeklyReport?.needs_help ?? false, href: "/weekly-reports", } : null, weeklySummary: teamWeeklySummary ? { ...teamWeeklySummary, href: "/weekly-reports" } : null, todoSummary: featureEnabled.todoBoard ? { openCount: todoRows.length, highPriorityCount: todoRows.filter((item) => item.priority === "high").length, href: "/todos", } : null, projectSummary: featureEnabled.projectDomain ? { myCount: myProjectCount, overdueMilestonesCount: overdueProjectMilestoneCount, highPriorityTaskCount: highPriorityProjectTaskCount, openRiskCount: openProjectRiskCount, followUpDueTodayCount: projectFollowUpDueTodayCount, closurePendingProjectCount, href: "/projects", } : null, peopleLifecycle: featureEnabled.employeeLifecycle && isManagerView ? { onboardingCount, offboardingCount, assetReturnPendingCount, href: "/people", } : null, roleCapabilities, financeSummary: roleCapabilities.canUseFinance ? { pendingBatchCount: pendingFinanceBatchCount, failedItemCount: failedFinanceItemCount, riskCount, paymentBatchEnabled: featureEnabled.paymentBatch, href: "/ap-operations", } : null, adoptionSummary, recentActivities, teamStatusBoard, }); }); fastify.get("/portal/team-status", { preHandler: fastify.authenticate }, async (request, reply) => { const today = currentDateString(fastify.config.appTimezone); const teamStatusBoard = await buildTeamStatusBoard(fastify.prisma, today); return sendData(reply, request, teamStatusBoard); }); } module.exports = portalRoutes;