835 行
32 KiB
JavaScript
835 行
32 KiB
JavaScript
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;
|