From bef69da0d1296505d17df3064456349c583e6e2f Mon Sep 17 00:00:00 2001 From: jayaruperera Date: Wed, 2 Jul 2025 10:36:35 +0530 Subject: [PATCH] feat: Add project activity logs API and frontend components - Implemented a new API route for fetching project activity logs. - Created a service for handling API requests related to project activity logs. - Developed Redux slice for managing activity log state. - Added components for displaying activity logs, including a virtualized list for performance. - Implemented filtering options for activity logs. - Added PDF export functionality for activity logs. - Integrated activity log view into the project view with appropriate UI elements. --- .../controllers/activity-logs-controller.ts | 207 ++++++++- .../project-activity-logs-controller.ts | 291 ++++++++++++ .../routes/apis/activity-logs-api-router.ts | 2 + worklenz-backend/src/routes/apis/index.ts | 240 +++++----- .../apis/project-activity-logs-api-router.ts | 22 + worklenz-frontend/package-lock.json | 22 + worklenz-frontend/package.json | 1 + .../project-activity-logs-api.service.ts | 137 ++++++ .../project-activity-log.slice.ts | 122 +++++ .../src/lib/project/project-view-constants.ts | 7 + .../components/activity-log-item.tsx | 208 +++++++++ .../components/virtual-activity-list.tsx | 111 +++++ .../activityLog/project-view-activity-log.tsx | 418 ++++++++++++++++++ 13 files changed, 1663 insertions(+), 125 deletions(-) create mode 100644 worklenz-backend/src/controllers/project-activity-logs-controller.ts create mode 100644 worklenz-backend/src/routes/apis/project-activity-logs-api-router.ts create mode 100644 worklenz-frontend/src/api/projects/project-activity-logs-api.service.ts create mode 100644 worklenz-frontend/src/features/projects/activity-log/project-activity-log.slice.ts create mode 100644 worklenz-frontend/src/pages/projects/projectView/activityLog/components/activity-log-item.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/activityLog/components/virtual-activity-list.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/activityLog/project-view-activity-log.tsx diff --git a/worklenz-backend/src/controllers/activity-logs-controller.ts b/worklenz-backend/src/controllers/activity-logs-controller.ts index 4e1e5960..867563d5 100644 --- a/worklenz-backend/src/controllers/activity-logs-controller.ts +++ b/worklenz-backend/src/controllers/activity-logs-controller.ts @@ -1,18 +1,17 @@ import moment from "moment"; -import {IWorkLenzRequest} from "../interfaces/worklenz-request"; -import {IWorkLenzResponse} from "../interfaces/worklenz-response"; - +import Excel from "exceljs"; +import { IWorkLenzRequest } from "../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../interfaces/worklenz-response"; import db from "../config/db"; - -import {ServerResponse} from "../models/server-response"; +import { ServerResponse } from "../models/server-response"; import WorklenzControllerBase from "./worklenz-controller-base"; import HandleExceptions from "../decorators/handle-exceptions"; -import {formatDuration, formatLogText, getColor} from "../shared/utils"; +import { formatDuration, formatLogText, getColor } from "../shared/utils"; export default class ActivitylogsController extends WorklenzControllerBase { @HandleExceptions() public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const {id} = req.params; + const { id } = req.params; const q = `SELECT get_activity_logs_by_task($1) AS activity_logs;`; const result = await db.query(q, [id]); const [data] = result.rows; @@ -31,4 +30,196 @@ export default class ActivitylogsController extends WorklenzControllerBase { return res.status(200).send(new ServerResponse(true, data.activity_logs)); } -} + + @HandleExceptions() + public static async getByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + try { + console.log("Received request for project activity logs:", req.params, req.query); + const projectId = req.params.id; + const page = parseInt(req.query.page as string) || 1; + const size = parseInt(req.query.size as string) || 20; + const offset = (page - 1) * size; + const filterType = req.query.filter as string || "all"; + + // Add filter conditions + let filterClause = ""; + const filterParams = [projectId]; + let paramIndex = 2; + + if (filterType && filterType !== "all") { + filterClause = ` AND tal.attribute_type = $${paramIndex}`; + filterParams.push(filterType); + paramIndex++; + } + + // Defensive UUID regex for safe casting + const uuidRegex = "'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'"; + + const q = ` + SELECT + tal.id, + tal.task_id, + tal.attribute_type, + tal.log_type, + tal.old_value, + tal.new_value, + tal.prev_string, + tal.next_string, + tal.created_at, + + -- Task details + (SELECT name FROM tasks WHERE id = tal.task_id) AS task_name, + (SELECT task_no FROM tasks WHERE id = tal.task_id) AS task_no, + CONCAT((SELECT key FROM projects WHERE id = $1), '-', (SELECT task_no FROM tasks WHERE id = tal.task_id)) AS task_key, + + -- User details + (SELECT ROW_TO_JSON(user_data) FROM ( + SELECT + u.id, + u.name, + u.avatar_url, + u.email + FROM users u + WHERE u.id = tal.user_id + ) user_data) AS done_by, + + -- Status details for status changes (safe UUID cast) + CASE + WHEN tal.attribute_type = 'status' AND tal.old_value ~ ${uuidRegex} THEN + (SELECT ROW_TO_JSON(status_data) FROM ( + SELECT + ts.name + FROM task_statuses ts + WHERE ts.id = tal.old_value::UUID + ) status_data) + ELSE NULL + END AS previous_status, + + CASE + WHEN tal.attribute_type = 'status' AND tal.new_value ~ ${uuidRegex} THEN + (SELECT ROW_TO_JSON(status_data) FROM ( + SELECT + ts.name + FROM task_statuses ts + WHERE ts.id = tal.new_value::UUID + ) status_data) + ELSE NULL + END AS next_status, + + -- Priority details for priority changes (safe UUID cast) + CASE + WHEN tal.attribute_type = 'priority' AND tal.old_value ~ ${uuidRegex} THEN + (SELECT ROW_TO_JSON(priority_data) FROM ( + SELECT + tp.name + FROM task_priorities tp + WHERE tp.id = tal.old_value::UUID + ) priority_data) + ELSE NULL + END AS previous_priority, + + CASE + WHEN tal.attribute_type = 'priority' AND tal.new_value ~ ${uuidRegex} THEN + (SELECT ROW_TO_JSON(priority_data) FROM ( + SELECT + tp.name + FROM task_priorities tp + WHERE tp.id = tal.new_value::UUID + ) priority_data) + ELSE NULL + END AS next_priority, + + -- Assigned user details for assignee changes (safe UUID cast) + CASE + WHEN tal.attribute_type = 'assignee' AND tal.new_value ~ ${uuidRegex} THEN + (SELECT ROW_TO_JSON(user_data) FROM ( + SELECT + u.id, + u.name, + u.avatar_url, + u.email + FROM users u + WHERE u.id = tal.new_value::UUID + ) user_data) + ELSE NULL + END AS assigned_user + + FROM task_activity_logs tal + WHERE tal.project_id = $1${filterClause} + ORDER BY tal.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + const countQuery = ` + SELECT COUNT(*) as total + FROM task_activity_logs + WHERE project_id = $1${filterClause} + `; + + const [result, countResult] = await Promise.all([ + db.query(q, [...filterParams, size, offset]), + db.query(countQuery, filterType && filterType !== "all" ? [projectId, filterType] : [projectId]) + ]); + + const total = parseInt(countResult.rows[0]?.total || "0"); + + // Format the logs + for (const log of result.rows) { + if (log.attribute_type === "estimation") { + log.previous = formatDuration(moment.duration(log.old_value, "minutes")); + log.current = formatDuration(moment.duration(log.new_value, "minutes")); + } else { + log.previous = log.old_value; + log.current = log.new_value; + } + + // Add color to users + if (log.assigned_user) { + log.assigned_user.color_code = getColor(log.assigned_user.name); + } + + if (log.done_by) { + log.done_by.color_code = getColor(log.done_by.name); + } + + // Add default colors for status and priority since table doesn't have color_code + if (log.previous_status) { + log.previous_status.color_code = "#d9d9d9"; // Default gray color + } + if (log.next_status) { + log.next_status.color_code = "#1890ff"; // Default blue color + } + if (log.previous_priority) { + log.previous_priority.color_code = "#d9d9d9"; // Default gray color + } + if (log.next_priority) { + log.next_priority.color_code = "#ff4d4f"; // Default red color for priority + } + + // Generate log text + log.log_text = await formatLogText(log); + log.attribute_type = log.attribute_type?.replace(/_/g, " "); + } + + const response = { + logs: result.rows, + pagination: { + current: page, + pageSize: size, + total, + totalPages: Math.ceil(total / size) + } + }; + + return res.status(200).send(new ServerResponse(true, response)); + } catch (error: any) { + console.error("❌ Error in getByProjectId:", error); + return res.status(500).send(new ServerResponse(false, null, `Internal server error: ${error.message}`)); + } + } + + @HandleExceptions() + public static async exportProjectActivityLogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + // ...keep your export logic as is... + } +} \ No newline at end of file diff --git a/worklenz-backend/src/controllers/project-activity-logs-controller.ts b/worklenz-backend/src/controllers/project-activity-logs-controller.ts new file mode 100644 index 00000000..52c19d35 --- /dev/null +++ b/worklenz-backend/src/controllers/project-activity-logs-controller.ts @@ -0,0 +1,291 @@ +import { IWorkLenzRequest } from "../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../interfaces/worklenz-response"; +import moment from "moment"; +import db from "../config/db"; +import { ServerResponse } from "../models/server-response"; +import WorklenzControllerBase from "./worklenz-controller-base"; +import HandleExceptions from "../decorators/handle-exceptions"; + +// --- Helpers ------------------------------------------------------------- + +function formatDuration(duration: moment.Duration | null): string { + if (!duration) return "0m"; + const hours = Math.floor(duration.asHours()); + const minutes = duration.minutes(); + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; +} + +function generateLogText(attributeType: string): string { + const map: Record = { + name: "updated task name", + status: "changed status", + priority: "changed priority", + assignee: "updated assignee", + end_date: "changed due date", + start_date: "changed start date", + estimation: "updated time estimation", + description: "updated description", + phase: "changed phase", + labels: "updated labels", + }; + return map[attributeType] || "made changes to"; +} + +function isValidUuid(id?: string): boolean { + return !!id && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id); +} + +// Get a consistent color for a user based on their name +function getColorFromName(name: string): string { + if (!name) return "#1890ff"; + + const colors = [ + "#f56a00", "#7265e6", "#ffbf00", "#00a2ae", + "#1890ff", "#52c41a", "#eb2f96", "#faad14", + "#722ed1", "#13c2c2", "#fa8c16", "#a0d911" + ]; + + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + + return colors[Math.abs(hash) % colors.length]; +} + +// --- Controller ---------------------------------------------------------- + +export default class ProjectActivityLogsController extends WorklenzControllerBase { + @HandleExceptions() + public static async getByProjectId( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + // 1) Extract & validate inputs + const projectId = req.params.id; + if (!isValidUuid(projectId)) { + return res + .status(400) + .json({ done: false, body: null, error: "Invalid project ID." }); + } + + const page = parseInt(req.query.page as string, 10) || 1; + const size = parseInt(req.query.size as string, 10) || 20; + const offset = (page - 1) * size; + const filterType = (req.query.filter as string) || "all"; + const allowedFilters = [ + "all","name","status","priority","assignee", + "end_date","start_date","estimation","description","phase" + ]; + if (!allowedFilters.includes(filterType)) { + return res + .status(400) + .json({ done: false, body: null, error: "Invalid filter type." }); + } + + // 2) Build parameterized SQL + let mainQuery = ` + SELECT + tal.id, + tal.task_id, + tal.user_id, + tal.attribute_type, + tal.log_type, + tal.old_value, + tal.new_value, + tal.prev_string, + tal.next_string, + tal.created_at, + t.name AS task_name, + t.task_no AS task_no, + p.key AS project_key, + + -- Include user details directly + u.id AS user_id, + u.name AS user_name, + u.email AS user_email, + u.avatar_url AS user_avatar_url + FROM task_activity_logs tal + LEFT JOIN tasks t ON tal.task_id = t.id + LEFT JOIN projects p ON tal.project_id = p.id + LEFT JOIN users u ON tal.user_id = u.id + WHERE tal.project_id = $1 + `; + + const queryParams: any[] = [projectId]; + if (filterType !== "all") { + mainQuery += ` AND tal.attribute_type = $2`; + queryParams.push(filterType); + } + mainQuery += ` ORDER BY tal.created_at DESC`; + // placeholders for LIMIT / OFFSET + const limitIdx = queryParams.length + 1; + const offsetIdx = queryParams.length + 2; + mainQuery += ` LIMIT $${limitIdx} OFFSET $${offsetIdx}`; + queryParams.push(size, offset); + + // Count query + const countQuery = ` + SELECT COUNT(*) AS total + FROM task_activity_logs tal + WHERE tal.project_id = $1 + `; + const countParams = filterType !== "all" + ? [projectId, filterType] + : [projectId]; + + try { + // 3) Execute SQL + const [dataResult, countResult] = await Promise.all([ + db.query(mainQuery, queryParams), + db.query( + countQuery + (filterType !== "all" ? ` AND tal.attribute_type = $2` : ""), + countParams + ), + ]); + + const total = parseInt(countResult.rows[0]?.total || "0", 10); + + // 4) Transform rows + const rows = dataResult.rows; + const logs = await Promise.all(rows.map(async (r: any) => { + const log: any = { ...r }; + // Correctly structure user information + log.done_by = { + id: r.user_id || "", + name: r.user_name || "Unknown User", + avatar_url: r.user_avatar_url, + email: r.user_email || "", + color_code: r.user_name ? getColorFromName(r.user_name) : "#1890ff" + }; + // task key + log.task_key = r.project_key && r.task_no ? + `${r.project_key}-${r.task_no}` : + `TASK-${r.task_id?.substring(0, 8) || "unknown"}`; + + // duration / estimation formatting + if (log.attribute_type === "estimation") { + const oldMin = parseInt(log.old_value, 10); + const newMin = parseInt(log.new_value, 10); + log.previous = !isNaN(oldMin) + ? formatDuration(moment.duration(oldMin, "minutes")) + : log.old_value; + log.current = !isNaN(newMin) + ? formatDuration(moment.duration(newMin, "minutes")) + : log.new_value; + } else { + log.previous = log.old_value; + log.current = log.new_value; + } + // human‐friendly action + log.log_text = generateLogText(r.attribute_type); + + // Handle status changes + if (log.attribute_type === "status" && log.old_value && isValidUuid(log.old_value)) { + try { + const prevStatus = await db.query( + `SELECT name, color_code FROM task_statuses WHERE id = $1`, + [log.old_value] + ); + if (prevStatus.rows.length > 0) { + log.previous_status = { + name: prevStatus.rows[0].name, + color_code: prevStatus.rows[0].color_code || "#d9d9d9" + }; + } + } catch (err) { + console.error("Error fetching previous status:", err); + } + } + + if (log.attribute_type === "status" && log.new_value && isValidUuid(log.new_value)) { + try { + const nextStatus = await db.query( + `SELECT name, color_code FROM task_statuses WHERE id = $1`, + [log.new_value] + ); + if (nextStatus.rows.length > 0) { + log.next_status = { + name: nextStatus.rows[0].name, + color_code: nextStatus.rows[0].color_code || "#1890ff" + }; + } + } catch (err) { + console.error("Error fetching next status:", err); + } + } + // Handle priority changes + if (log.attribute_type === "priority" && log.old_value && isValidUuid(log.old_value)) { + try { + const prevPriority = await db.query( + `SELECT name, color_code FROM task_priorities WHERE id = $1`, + [log.old_value] + ); + if (prevPriority.rows.length > 0) { + log.previous_priority = { + name: prevPriority.rows[0].name, + color_code: prevPriority.rows[0].color_code || "#d9d9d9" + }; + } + } catch (err) { + console.error("Error fetching previous priority:", err); + } + } + + if (log.attribute_type === "priority" && log.new_value && isValidUuid(log.new_value)) { + try { + const nextPriority = await db.query( + `SELECT name, color_code FROM task_priorities WHERE id = $1`, + [log.new_value] + ); + if (nextPriority.rows.length > 0) { + log.next_priority = { + name: nextPriority.rows[0].name, + color_code: nextPriority.rows[0].color_code || "#ff4d4f" + }; + } + } catch (err) { + console.error("Error fetching next priority:", err); + } + } +// Handle assignee changes + if (log.attribute_type === "assignee" && log.new_value && isValidUuid(log.new_value)) { + try { + const assignedUser = await db.query( + `SELECT id, name, avatar_url, email FROM users WHERE id = $1`, + [log.new_value] + ); + if (assignedUser.rows.length > 0) { + log.assigned_user = { + ...assignedUser.rows[0], + color_code: getColorFromName(assignedUser.rows[0].name) + }; + } + } catch (err) { + console.error("Error fetching assigned user:", err); + } + } + + return log; + })); + + // 5) Send back a clean response + const response = { + logs, + pagination: { + current: page, + pageSize: size, + total, + totalPages: Math.ceil(total / size), + } + }; + + return res.status(200).send(new ServerResponse(true, response)); + } catch (err) { + console.error("🔥 getByProjectId error:", err); + return res.status(500).send( + new ServerResponse(false, null, "Internal server error fetching logs.") + ); + } + } +} \ No newline at end of file diff --git a/worklenz-backend/src/routes/apis/activity-logs-api-router.ts b/worklenz-backend/src/routes/apis/activity-logs-api-router.ts index 064f310c..39125cdd 100644 --- a/worklenz-backend/src/routes/apis/activity-logs-api-router.ts +++ b/worklenz-backend/src/routes/apis/activity-logs-api-router.ts @@ -7,5 +7,7 @@ import safeControllerFunction from "../../shared/safe-controller-function"; const activityLogsApiRouter = express.Router(); activityLogsApiRouter.get("/:id", idParamValidator, safeControllerFunction(ActivitylogsController.get)); +activityLogsApiRouter.get("/project/:id", idParamValidator, safeControllerFunction(ActivitylogsController.getByProjectId)); +activityLogsApiRouter.get("/project/:id/export", safeControllerFunction(ActivitylogsController.exportProjectActivityLogs)); export default activityLogsApiRouter; diff --git a/worklenz-backend/src/routes/apis/index.ts b/worklenz-backend/src/routes/apis/index.ts index 5a2019c8..31bca60a 100644 --- a/worklenz-backend/src/routes/apis/index.ts +++ b/worklenz-backend/src/routes/apis/index.ts @@ -1,120 +1,126 @@ -import express from "express"; - -import AccessControlsController from "../../controllers/access-controls-controller"; -import AuthController from "../../controllers/auth-controller"; -import LogsController from "../../controllers/logs-controller"; -import OverviewController from "../../controllers/overview-controller"; -import TaskPrioritiesController from "../../controllers/task-priorities-controller"; - -import attachmentsApiRouter from "./attachments-api-router"; -import clientsApiRouter from "./clients-api-router"; -import jobTitlesApiRouter from "./job-titles-api-router"; -import notificationsApiRouter from "./notifications-api-router"; -import personalOverviewApiRouter from "./personal-overview-api-router"; -import projectMembersApiRouter from "./project-members-api-router"; -import projectsApiRouter from "./projects-api-router"; -import settingsApiRouter from "./settings-api-router"; -import statusesApiRouter from "./statuses-api-router"; -import subTasksApiRouter from "./sub-tasks-api-router"; -import taskCommentsApiRouter from "./task-comments-api-router"; -import taskWorkLogApiRouter from "./task-work-log-api-router"; -import tasksApiRouter from "./tasks-api-router"; -import teamMembersApiRouter from "./team-members-api-router"; -import teamsApiRouter from "./teams-api-router"; -import timezonesApiRouter from "./timezones-api-router"; -import todoListApiRouter from "./todo-list-api-router"; -import projectStatusesApiRouter from "./project-statuses-api-router"; -import labelsApiRouter from "./labels-api-router"; -import sharedProjectsApiRouter from "./shared-projects-api-router"; -import resourceAllocationApiRouter from "./resource-allocation-api-router"; -import taskTemplatesApiRouter from "./task-templates-api-router"; -import projectInsightsApiRouter from "./project-insights-api-router"; -import passwordValidator from "../../middlewares/validators/password-validator"; -import adminCenterApiRouter from "./admin-center-api-router"; -import reportingApiRouter from "./reporting-api-router"; -import activityLogsApiRouter from "./activity-logs-api-router"; -import safeControllerFunction from "../../shared/safe-controller-function"; -import projectFoldersApiRouter from "./project-folders-api-router"; -import taskPhasesApiRouter from "./task-phases-api-router"; -import projectCategoriesApiRouter from "./project-categories-api-router"; -import homePageApiRouter from "./home-page-api-router"; -import ganttApiRouter from "./gantt-api-router"; -import projectCommentsApiRouter from "./project-comments-api-router"; -import reportingExportApiRouter from "./reporting-export-api-router"; -import projectHealthsApiRouter from "./project-healths-api-router"; -import ptTasksApiRouter from "./pt-tasks-api-router"; -import projectTemplatesApiRouter from "./project-templates-api"; -import ptTaskPhasesApiRouter from "./pt_task-phases-api-router"; -import ptStatusesApiRouter from "./pt-statuses-api-router"; -import workloadApiRouter from "./gannt-apis/workload-api-router"; -import roadmapApiRouter from "./gannt-apis/roadmap-api-router"; -import scheduleApiRouter from "./gannt-apis/schedule-api-router"; -import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router"; -import projectManagerApiRouter from "./project-managers-api-router"; - -import billingApiRouter from "./billing-api-router"; -import taskDependenciesApiRouter from "./task-dependencies-api-router"; - -import taskRecurringApiRouter from "./task-recurring-api-router"; - import customColumnsApiRouter from "./custom-columns-api-router"; +import express from "express"; + +import AccessControlsController from "../../controllers/access-controls-controller"; +import AuthController from "../../controllers/auth-controller"; +import LogsController from "../../controllers/logs-controller"; +import OverviewController from "../../controllers/overview-controller"; +import TaskPrioritiesController from "../../controllers/task-priorities-controller"; + +import attachmentsApiRouter from "./attachments-api-router"; +import clientsApiRouter from "./clients-api-router"; +import jobTitlesApiRouter from "./job-titles-api-router"; +import notificationsApiRouter from "./notifications-api-router"; +import personalOverviewApiRouter from "./personal-overview-api-router"; +import projectMembersApiRouter from "./project-members-api-router"; +import projectsApiRouter from "./projects-api-router"; +import settingsApiRouter from "./settings-api-router"; +import statusesApiRouter from "./statuses-api-router"; +import subTasksApiRouter from "./sub-tasks-api-router"; +import taskCommentsApiRouter from "./task-comments-api-router"; +import taskWorkLogApiRouter from "./task-work-log-api-router"; +import tasksApiRouter from "./tasks-api-router"; +import teamMembersApiRouter from "./team-members-api-router"; +import teamsApiRouter from "./teams-api-router"; +import timezonesApiRouter from "./timezones-api-router"; +import todoListApiRouter from "./todo-list-api-router"; +import projectStatusesApiRouter from "./project-statuses-api-router"; +import labelsApiRouter from "./labels-api-router"; +import sharedProjectsApiRouter from "./shared-projects-api-router"; +import resourceAllocationApiRouter from "./resource-allocation-api-router"; +import taskTemplatesApiRouter from "./task-templates-api-router"; +import projectInsightsApiRouter from "./project-insights-api-router"; +import passwordValidator from "../../middlewares/validators/password-validator"; +import adminCenterApiRouter from "./admin-center-api-router"; +import reportingApiRouter from "./reporting-api-router"; +import activityLogsApiRouter from "./activity-logs-api-router"; +import safeControllerFunction from "../../shared/safe-controller-function"; +import projectFoldersApiRouter from "./project-folders-api-router"; +import taskPhasesApiRouter from "./task-phases-api-router"; +import projectCategoriesApiRouter from "./project-categories-api-router"; +import homePageApiRouter from "./home-page-api-router"; +import ganttApiRouter from "./gantt-api-router"; +import projectCommentsApiRouter from "./project-comments-api-router"; +import reportingExportApiRouter from "./reporting-export-api-router"; +import projectHealthsApiRouter from "./project-healths-api-router"; +import ptTasksApiRouter from "./pt-tasks-api-router"; +import projectTemplatesApiRouter from "./project-templates-api"; +import ptTaskPhasesApiRouter from "./pt_task-phases-api-router"; +import ptStatusesApiRouter from "./pt-statuses-api-router"; +import workloadApiRouter from "./gannt-apis/workload-api-router"; +import roadmapApiRouter from "./gannt-apis/roadmap-api-router"; +import scheduleApiRouter from "./gannt-apis/schedule-api-router"; +import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router"; +import projectManagerApiRouter from "./project-managers-api-router"; + +import billingApiRouter from "./billing-api-router"; +import taskDependenciesApiRouter from "./task-dependencies-api-router"; + +import taskRecurringApiRouter from "./task-recurring-api-router"; + +import customColumnsApiRouter from "./custom-columns-api-router"; + +import projectActivityLogsApiRouter from "./project-activity-logs-api-router"; + +const api = express.Router(); + +api.use("/projects", projectsApiRouter); +api.use("/team-members", teamMembersApiRouter); +api.use("/job-titles", jobTitlesApiRouter); +api.use("/clients", clientsApiRouter); +api.use("/teams", teamsApiRouter); +api.use("/tasks", tasksApiRouter); +api.use("/settings", settingsApiRouter); +api.use("/personal-overview", personalOverviewApiRouter); +api.use("/statuses", statusesApiRouter); +api.use("/todo-list", todoListApiRouter); +api.use("/notifications", notificationsApiRouter); +api.use("/attachments", attachmentsApiRouter); +api.use("/sub-tasks", subTasksApiRouter); +api.use("/project-members", projectMembersApiRouter); +api.use("/task-time-log", taskWorkLogApiRouter); +api.use("/task-comments", taskCommentsApiRouter); +api.use("/timezones", timezonesApiRouter); +api.use("/project-statuses", projectStatusesApiRouter); +api.use("/labels", labelsApiRouter); +api.use("/resource-allocation", resourceAllocationApiRouter); +api.use("/shared/projects", sharedProjectsApiRouter); +api.use("/task-templates", taskTemplatesApiRouter); +api.use("/project-insights", projectInsightsApiRouter); +api.use("/admin-center", adminCenterApiRouter); +api.use("/reporting", reportingApiRouter); +api.use("/activity-logs", activityLogsApiRouter); +api.use("/projects-folders", projectFoldersApiRouter); +api.use("/task-phases", taskPhasesApiRouter); +api.use("/project-categories", projectCategoriesApiRouter); +api.use("/home", homePageApiRouter); +api.use("/gantt", ganttApiRouter); +api.use("/project-comments", projectCommentsApiRouter); +api.use("/reporting-export", reportingExportApiRouter); +api.use("/project-healths", projectHealthsApiRouter); +api.use("/project-templates", projectTemplatesApiRouter); +api.use("/pt-tasks", ptTasksApiRouter); +api.use("/pt-task-phases", ptTaskPhasesApiRouter); +api.use("/pt-statuses", ptStatusesApiRouter); +api.use("/workload-gannt", workloadApiRouter); +api.use("/roadmap-gannt", roadmapApiRouter); +api.use("/schedule-gannt", scheduleApiRouter); +api.use("/schedule-gannt-v2", scheduleApiV2Router); +api.use("/project-managers", projectManagerApiRouter); + +api.get("/overview/:id", safeControllerFunction(OverviewController.getById)); +api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get)); +api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword)); +api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles)); +api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog)); + +api.use("/billing", billingApiRouter); +api.use("/task-dependencies", taskDependenciesApiRouter); + +api.use("/task-recurring", taskRecurringApiRouter); -const api = express.Router(); - -api.use("/projects", projectsApiRouter); -api.use("/team-members", teamMembersApiRouter); -api.use("/job-titles", jobTitlesApiRouter); -api.use("/clients", clientsApiRouter); -api.use("/teams", teamsApiRouter); -api.use("/tasks", tasksApiRouter); -api.use("/settings", settingsApiRouter); -api.use("/personal-overview", personalOverviewApiRouter); -api.use("/statuses", statusesApiRouter); -api.use("/todo-list", todoListApiRouter); -api.use("/notifications", notificationsApiRouter); -api.use("/attachments", attachmentsApiRouter); -api.use("/sub-tasks", subTasksApiRouter); -api.use("/project-members", projectMembersApiRouter); -api.use("/task-time-log", taskWorkLogApiRouter); -api.use("/task-comments", taskCommentsApiRouter); -api.use("/timezones", timezonesApiRouter); -api.use("/project-statuses", projectStatusesApiRouter); -api.use("/labels", labelsApiRouter); -api.use("/resource-allocation", resourceAllocationApiRouter); -api.use("/shared/projects", sharedProjectsApiRouter); -api.use("/task-templates", taskTemplatesApiRouter); -api.use("/project-insights", projectInsightsApiRouter); -api.use("/admin-center", adminCenterApiRouter); -api.use("/reporting", reportingApiRouter); -api.use("/activity-logs", activityLogsApiRouter); -api.use("/projects-folders", projectFoldersApiRouter); -api.use("/task-phases", taskPhasesApiRouter); -api.use("/project-categories", projectCategoriesApiRouter); -api.use("/home", homePageApiRouter); -api.use("/gantt", ganttApiRouter); -api.use("/project-comments", projectCommentsApiRouter); -api.use("/reporting-export", reportingExportApiRouter); -api.use("/project-healths", projectHealthsApiRouter); -api.use("/project-templates", projectTemplatesApiRouter); -api.use("/pt-tasks", ptTasksApiRouter); -api.use("/pt-task-phases", ptTaskPhasesApiRouter); -api.use("/pt-statuses", ptStatusesApiRouter); -api.use("/workload-gannt", workloadApiRouter); -api.use("/roadmap-gannt", roadmapApiRouter); -api.use("/schedule-gannt", scheduleApiRouter); -api.use("/schedule-gannt-v2", scheduleApiV2Router); -api.use("/project-managers", projectManagerApiRouter); - -api.get("/overview/:id", safeControllerFunction(OverviewController.getById)); -api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get)); -api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword)); -api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles)); -api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog)); - -api.use("/billing", billingApiRouter); -api.use("/task-dependencies", taskDependenciesApiRouter); - -api.use("/task-recurring", taskRecurringApiRouter); - api.use("/custom-columns", customColumnsApiRouter); +api.use("/activity-logs", activityLogsApiRouter); -export default api; +api.use("/project-activity-logs", projectActivityLogsApiRouter); + +export default api; diff --git a/worklenz-backend/src/routes/apis/project-activity-logs-api-router.ts b/worklenz-backend/src/routes/apis/project-activity-logs-api-router.ts new file mode 100644 index 00000000..3946aac7 --- /dev/null +++ b/worklenz-backend/src/routes/apis/project-activity-logs-api-router.ts @@ -0,0 +1,22 @@ +import express from "express"; +import idParamValidator from "../../middlewares/validators/id-param-validator"; +import safeControllerFunction from "../../shared/safe-controller-function"; +import ProjectActivityLogsController from "../../controllers/project-activity-logs-controller"; + +const projectActivityLogsApiRouter = express.Router(); + +// Match the client URL (/project/:projectId) and ensure param name lines up +projectActivityLogsApiRouter.get( + "/project/:id", + // Validate that id is a proper UUID + idParamValidator, + // Wrap the controller to catch and forward errors + safeControllerFunction(ProjectActivityLogsController.getByProjectId) +); + +// Optional health‐check endpoint +projectActivityLogsApiRouter.get("/test", (req, res) => { + res.json({ message: "Project activity logs router is working!" }); +}); + +export default projectActivityLogsApiRouter; \ No newline at end of file diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json index f75f2fce..83f223e9 100644 --- a/worklenz-frontend/package-lock.json +++ b/worklenz-frontend/package-lock.json @@ -63,6 +63,7 @@ "@types/node": "^20.8.4", "@types/react": "19.0.0", "@types/react-dom": "19.0.0", + "@types/react-window-infinite-loader": "^1.0.9", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "postcss": "^8.5.2", @@ -2449,6 +2450,27 @@ "@types/react": "*" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react-window-infinite-loader": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/react-window-infinite-loader/-/react-window-infinite-loader-1.0.9.tgz", + "integrity": "sha512-gEInTjQwURCnDOFyIEK2+fWB5gTjqwx30O62QfxA9stE5aiB6EWkGj4UMhc0axq7/FV++Gs/TGW8FtgEx0S6Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@types/react-window": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", diff --git a/worklenz-frontend/package.json b/worklenz-frontend/package.json index 31b7f8bf..ad3acef7 100644 --- a/worklenz-frontend/package.json +++ b/worklenz-frontend/package.json @@ -66,6 +66,7 @@ "@types/node": "^20.8.4", "@types/react": "19.0.0", "@types/react-dom": "19.0.0", + "@types/react-window-infinite-loader": "^1.0.9", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "postcss": "^8.5.2", diff --git a/worklenz-frontend/src/api/projects/project-activity-logs-api.service.ts b/worklenz-frontend/src/api/projects/project-activity-logs-api.service.ts new file mode 100644 index 00000000..3818f0b7 --- /dev/null +++ b/worklenz-frontend/src/api/projects/project-activity-logs-api.service.ts @@ -0,0 +1,137 @@ +import { AxiosError } from 'axios'; +import { IServerResponse } from '@/types/common.types'; +import apiClient from '../api-client'; +import { API_BASE_URL } from '@/shared/constants'; + +export interface IProjectActivityLog { + id: string; + task_id: string; + task_name: string; + task_no: number; + task_key: string; + attribute_type: string; + log_type: string; + old_value?: string; + new_value?: string; + prev_string?: string; + next_string?: string; + previous?: string; + current?: string; + created_at: string; + log_text?: string; + done_by?: { + id: string; + name: string; + avatar_url?: string; + email: string; + color_code?: string; + }; + assigned_user?: { + id: string; + name: string; + avatar_url?: string; + email: string; + color_code?: string; + }; + previous_status?: { + name: string; + color_code: string; + }; + next_status?: { + name: string; + color_code: string; + }; + previous_priority?: { + name: string; + color_code: string; + }; + next_priority?: { + name: string; + color_code: string; + }; +} + +export interface IProjectActivityLogsResponse { + logs: IProjectActivityLog[]; + pagination: { + current: number; + pageSize: number; + total: number; + totalPages: number; + }; +} + +export interface IActivityLogFilter { + label: string; + value: string; +} + +class ProjectActivityLogsApiService { + /** + * Fetch paginated activity logs for a project. + */ + async getActivityLogsByProjectId( + projectId: string, + page: number = 1, + size: number = 20, + filter: string = 'all' + ): Promise> { + try { + const endpoint = `${API_BASE_URL}/project-activity-logs/project/${projectId}`; + const params: Record = { page, size }; + + // Only send a filter param when it's not "all" + if (filter && filter !== 'all') { + params.filter = filter; + } + + console.log('Making request to:', endpoint, 'with params:', params); + + const response = await apiClient.get< + IServerResponse + >(endpoint, { params }); + + return response.data; + } catch (err) { + // Handle Axios errors explicitly + if ((err as AxiosError).isAxiosError) { + const axiosErr = err as AxiosError; + console.group('📡 ProjectActivityLogs API Error'); + console.log('URL:', axiosErr.config?.url); + console.log('Status:', axiosErr.response?.status); + console.log('Response data:', axiosErr.response?.data); + console.groupEnd(); + + // Surface a clean message from the server or default + const serverMsg = + (axiosErr.response?.data as any)?.error || + `Request failed with status code ${axiosErr.response?.status}`; + throw new Error(serverMsg); + } + + // Non-Axios errors + console.error('Unexpected error in getActivityLogsByProjectId:', err); + throw err; + } + } + + /** + * Return the filter dropdown options. + */ + getFilterOptions(): IActivityLogFilter[] { + return [ + { label: 'All Activities', value: 'all' }, + { label: 'Task Name Changes', value: 'name' }, + { label: 'Status Changes', value: 'status' }, + { label: 'Priority Changes', value: 'priority' }, + { label: 'Assignee Changes', value: 'assignee' }, + { label: 'Due Date Changes', value: 'end_date' }, + { label: 'Start Date Changes', value: 'start_date' }, + { label: 'Estimation Changes', value: 'estimation' }, + { label: 'Description Changes', value: 'description' }, + { label: 'Phase Changes', value: 'phase' }, + ]; + } +} + +export const projectActivityLogsApiService = new ProjectActivityLogsApiService(); \ No newline at end of file diff --git a/worklenz-frontend/src/features/projects/activity-log/project-activity-log.slice.ts b/worklenz-frontend/src/features/projects/activity-log/project-activity-log.slice.ts new file mode 100644 index 00000000..ed8b36ea --- /dev/null +++ b/worklenz-frontend/src/features/projects/activity-log/project-activity-log.slice.ts @@ -0,0 +1,122 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import { projectActivityLogsApiService, IProjectActivityLog } from '../../../api/projects/project-activity-logs-api.service'; + +interface ProjectActivityLogState { + logs: IProjectActivityLog[]; + loading: boolean; + loadingMore: boolean; + error: string | null; + filterType: string; + totalLogs: number; + currentPage: number; + pageSize: number; + hasNextPage: boolean; + isItemLoaded: (index: number) => boolean; +} + +const initialState: ProjectActivityLogState = { + logs: [], + loading: false, + loadingMore: false, + error: null, + filterType: 'all', + totalLogs: 0, + currentPage: 1, + pageSize: 20, + hasNextPage: true, + isItemLoaded: (index: number) => false, +}; + +export const fetchProjectActivityLogs = createAsyncThunk( + 'projectActivityLog/fetchLogs', + async ({ + projectId, + page = 1, + size = 20, + filter = 'all', + append = false + }: { + projectId: string; + page?: number; + size?: number; + filter?: string; + append?: boolean; + }) => { + const response = await projectActivityLogsApiService.getActivityLogsByProjectId(projectId, page, size, filter); + return { ...response.body, append, page }; + } +); + +const projectActivityLogSlice = createSlice({ + name: 'projectActivityLog', + initialState, + reducers: { + setFilterType: (state, action: PayloadAction) => { + state.filterType = action.payload; + state.currentPage = 1; + state.logs = []; + state.hasNextPage = true; + }, + setCurrentPage: (state, action: PayloadAction) => { + state.currentPage = action.payload; + }, + clearLogs: (state) => { + state.logs = []; + state.totalLogs = 0; + state.currentPage = 1; + state.error = null; + state.hasNextPage = true; + }, + updateIsItemLoaded: (state) => { + state.isItemLoaded = (index: number) => { + return index < state.logs.length; + }; + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchProjectActivityLogs.pending, (state, action) => { + const { append } = action.meta.arg; + if (append) { + state.loadingMore = true; + } else { + state.loading = true; + } + state.error = null; + }) + .addCase(fetchProjectActivityLogs.fulfilled, (state, action) => { + state.loading = false; + state.loadingMore = false; + + if (action.payload) { + const { logs, pagination, append } = action.payload; + + if (logs && Array.isArray(logs)) { + if (append) { + state.logs = [...state.logs, ...logs]; + } else { + state.logs = logs; + } + + state.totalLogs = pagination?.total || 0; + state.currentPage = pagination?.current || 1; + state.hasNextPage = state.logs.length < state.totalLogs; + } + } + + state.isItemLoaded = (index: number) => index < state.logs.length; + }) + .addCase(fetchProjectActivityLogs.rejected, (state, action) => { + state.loading = false; + state.loadingMore = false; + state.error = action.error.message || 'Failed to fetch activity logs'; + if (!action.meta.arg.append) { + state.logs = []; + state.totalLogs = 0; + } + }); + }, +}); + +export const { setFilterType, setCurrentPage, clearLogs, updateIsItemLoaded } = projectActivityLogSlice.actions; +export default projectActivityLogSlice.reducer; \ No newline at end of file diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index fc4b8e87..758611ad 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -5,6 +5,7 @@ import ProjectViewMembers from '@/pages/projects/projectView/members/project-vie import ProjectViewUpdates from '@/pages/projects/project-view-1/updates/project-view-updates'; import ProjectViewTaskList from '@/pages/projects/projectView/taskList/project-view-task-list'; import ProjectViewBoard from '@/pages/projects/projectView/board/project-view-board'; +import ProjectViewActivityLog from '@/pages/projects/projectView/activityLog/project-view-activity-log'; // type of a tab items type TabItems = { @@ -67,4 +68,10 @@ export const tabItems: TabItems[] = [ label: 'Updates', element: React.createElement(ProjectViewUpdates), }, + { + index: 8, + key: 'activity-log', + label: 'Activity Log', + element: React.createElement(ProjectViewActivityLog), + }, ]; diff --git a/worklenz-frontend/src/pages/projects/projectView/activityLog/components/activity-log-item.tsx b/worklenz-frontend/src/pages/projects/projectView/activityLog/components/activity-log-item.tsx new file mode 100644 index 00000000..b5bb8b35 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/activityLog/components/activity-log-item.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { Avatar, Flex, Tag, Tooltip, Timeline, Skeleton, Typography } from 'antd'; +const { Text } = Typography; +import { ArrowRightOutlined, UserOutlined } from '@ant-design/icons'; +import { IProjectActivityLog } from '@/api/projects/project-activity-logs-api.service'; + +interface ActivityLogItemProps { + activity?: IProjectActivityLog; + isLoading?: boolean; + style?: React.CSSProperties; +} + +const ActivityLogItem: React.FC = ({ activity, isLoading, style }) => { + const formatTimeAgo = (dateString: string) => { + const now = new Date(); + const date = new Date(dateString); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diffInSeconds < 60) return 'Just now'; + if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`; + if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`; + return `${Math.floor(diffInSeconds / 86400)}d ago`; + }; + + const formatDateTime = (dateString: string) => { + return new Date(dateString).toLocaleString(); + }; + + const truncateText = (text?: string, maxLength: number = 50): string => { + if (!text) return ''; + return text.length > maxLength ? text.substring(0, maxLength) + '...' : text; + }; + + const renderAttributeValue = (activity: IProjectActivityLog) => { + switch (activity.attribute_type) { + case 'name': + return ( + + {truncateText(activity.previous)} + + {truncateText(activity.current)} + + ); + + case 'status': + return ( + + + {activity.previous_status?.name || 'None'} + + + + {activity.next_status?.name || 'None'} + + + ); + + case 'priority': + return ( + + + {activity.previous_priority?.name || 'None'} + + + + {activity.next_priority?.name || 'None'} + + + ); + + case 'assignee': + if (activity.log_type === 'assign' && activity.assigned_user) { + return ( + + Assigned to {activity.assigned_user.name} + + ); + } else if (activity.log_type === 'unassign') { + return ( + + Unassigned + + ); + } + break; + + case 'estimation': + return ( + + {activity.previous || '0m'} + + {activity.current || '0m'} + + ); + + case 'start date': + case 'end date': + return ( + + {activity.previous || 'None'} + + {activity.current || 'None'} + + ); + + case 'description': + return ( + + Description updated + + ); + + default: + if (activity.previous && activity.current) { + return ( + + {truncateText(activity.previous, 20)} + + {truncateText(activity.current, 20)} + + ); + } + return null; + } + }; + + if (isLoading || !activity) { + return ( +
+ +
+ + +
+ +
+
+
+
+
+ ); + } + + return ( +
+ +
+ + } + > + {!activity.done_by?.avatar_url && activity.done_by?.name?.charAt(0).toUpperCase()} + + +
+ + + {activity.done_by?.name} + + {activity.log_text} + + + + + {activity.task_key} + + + {activity.task_name} + + + + {renderAttributeValue(activity)} + +
+ + + {formatTimeAgo(activity.created_at)} + + +
+
+
+
+
+
+ ); +}; + +export default ActivityLogItem; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/activityLog/components/virtual-activity-list.tsx b/worklenz-frontend/src/pages/projects/projectView/activityLog/components/virtual-activity-list.tsx new file mode 100644 index 00000000..eed25186 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/activityLog/components/virtual-activity-list.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; +import InfiniteLoader from 'react-window-infinite-loader'; +import { Spin, Typography } from 'antd'; +import { IProjectActivityLog } from '../../../../../api/projects/project-activity-logs-api.service'; + +const { Text } = Typography; + +export interface VirtualActivityListProps { + logs: IProjectActivityLog[]; + hasNextPage: boolean; + isItemLoaded: (index: number) => boolean; + loadMoreItems: (startIndex: number, stopIndex: number) => Promise; + height: number; + itemHeight?: number; +} + +const VirtualActivityList: React.FC = ({ + logs, + hasNextPage, + isItemLoaded, + loadMoreItems, + height, + itemHeight = 80, // Reduced from 140 to 80 for tighter spacing +}) => { + // if there's more to load, we let InfiniteLoader think the list is one item longer + const itemCount = hasNextPage ? logs.length + 1 : logs.length; + + const renderRow = ({ index, style }: ListChildComponentProps) => { + const isLoadingRow = hasNextPage && index === logs.length; + + if (isLoadingRow) { + return ( +
+ + + Loading… + +
+ ); + } + + const log = logs[index]; + return ( +
+
+ {log.done_by?.name?.charAt(0).toUpperCase() || 'U'} +
+
+
+ {log.done_by?.name || 'Unknown'}{' '} + {log.log_text} +
+
+ {log.task_key}{' '} + {log.task_name} +
+
+ {new Date(log.created_at).toLocaleString()} +
+
+
+ ); + }; + + return ( + + {({ onItemsRendered, ref }) => ( + + {renderRow} + + )} + + ); +}; + +export default VirtualActivityList; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/activityLog/project-view-activity-log.tsx b/worklenz-frontend/src/pages/projects/projectView/activityLog/project-view-activity-log.tsx new file mode 100644 index 00000000..41436358 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/activityLog/project-view-activity-log.tsx @@ -0,0 +1,418 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { DownloadOutlined, FilterOutlined } from '@ant-design/icons'; +import { Button, Select, Spin, Typography, Empty, message } from 'antd'; +import { useParams } from 'react-router-dom'; +import jsPDF from 'jspdf'; +import { format } from 'date-fns'; + +import { + projectActivityLogsApiService, + IProjectActivityLog, +} from '../../../../api/projects/project-activity-logs-api.service'; +import VirtualActivityList from './components/virtual-activity-list'; + +const { Title, Text } = Typography; +const { Option } = Select; + +const ProjectViewActivityLog: React.FC = () => { + const { projectId } = useParams<{ projectId: string }>(); + const exportRef = useRef(null); + + // Data + paging + const [logs, setLogs] = useState([]); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize] = useState(20); + const [totalPages, setTotalPages] = useState(1); + + // UI state + const [filterType, setFilterType] = useState('all'); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [exportLoading, setExportLoading] = useState(false); + const [error, setError] = useState(null); + const [listHeight, setListHeight] = useState(600); + + // compute if more pages exist + const hasNextPage = currentPage < totalPages; + + // calculate list height + useEffect(() => { + const calc = () => { + const header = 200; + setListHeight(Math.max(400, window.innerHeight - header)); + }; + calc(); + window.addEventListener('resize', calc); + return () => window.removeEventListener('resize', calc); + }, []); + + // loader callbacks + const isItemLoaded = useCallback( + (index: number) => index < logs.length, + [logs.length] + ); + + const fetchPage = useCallback( + async (page: number, filter: string, append = false) => { + if (!projectId) return; + append ? setLoadingMore(true) : setLoading(true); + setError(null); + + try { + const res = await projectActivityLogsApiService.getActivityLogsByProjectId( + projectId, + page, + pageSize, + filter + ); + if (!res.done || !res.body) throw new Error('Invalid response'); + + const { logs: newLogs, pagination } = res.body; + setLogs(prev => (append ? [...prev, ...newLogs] : newLogs)); + setCurrentPage(pagination.current); + setTotalPages(pagination.totalPages); + } catch (e) { + setError(e instanceof Error ? e.message : 'Fetch failed'); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, + [projectId, pageSize] + ); + + const loadMoreItems = useCallback( + async (_startIndex: number, _stopIndex: number) => { + if (loadingMore || !hasNextPage) return; + await fetchPage(currentPage + 1, filterType, true); + }, + [currentPage, filterType, hasNextPage, loadingMore, fetchPage] + ); + + // initial + filter-driven load + useEffect(() => { + setLogs([]); + setCurrentPage(1); + setTotalPages(1); + fetchPage(1, filterType, false); + }, [projectId, filterType, fetchPage]); + + // handle filter + const onFilterChange = (value: string) => setFilterType(value); + + // Function to fetch all logs for export (paginated) + const fetchAllLogsForExport = async (): Promise => { + if (!projectId) return []; + + const allLogs: IProjectActivityLog[] = []; + let page = 1; + let totalPages = 1; + + message.loading('Fetching all activity logs for export...', 0); + + do { + try { + const res = await projectActivityLogsApiService.getActivityLogsByProjectId( + projectId, + page, + 100, // Use larger page size for export + filterType + ); + + if (!res.done || !res.body) break; + + const { logs: pageLogs, pagination } = res.body; + allLogs.push(...pageLogs); + totalPages = pagination.totalPages; + + // Update progress message + message.destroy(); + message.loading(`Fetching logs... Page ${page} of ${totalPages}`, 0); + + page++; + } catch (error) { + console.error('Error fetching logs for export:', error); + break; + } + } while (page <= totalPages); + + message.destroy(); + + return allLogs; + }; + + // Multi-page PDF export with all logs + const exportPdfWithAllLogs = async (projectName: string, allLogs: IProjectActivityLog[]) => { + if (allLogs.length === 0) return; + + const pdf = new jsPDF('p', 'mm', 'a4'); + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const marginX = 10; + const marginY = 30; + const contentWidth = pageWidth - (marginX * 2); + const contentHeight = pageHeight - marginY - 20; // Reserve space for header and footer + + // Constants for layout + const itemHeight = 18; // height per activity log item in mm + const itemsPerPage = Math.floor(contentHeight / itemHeight); + + // Helper function to add header to each page + const addPageHeader = (pageNum: number, totalPages: number) => { + pdf.setFontSize(16); + pdf.setFont('helvetica', 'bold'); + pdf.text( + `Activity Log - ${projectName}`, + pageWidth / 2, + 15, + { align: 'center' } + ); + + pdf.setFontSize(10); + pdf.setFont('helvetica', 'normal'); + pdf.text( + `Generated on ${format(new Date(), 'yyyy-MM-dd HH:mm')}`, + pageWidth / 2, + 20, + { align: 'center' } + ); + + if (filterType !== 'all') { + const filterLabel = projectActivityLogsApiService.getFilterOptions() + .find(f => f.value === filterType)?.label || filterType; + pdf.text( + `Filter: ${filterLabel}`, + pageWidth / 2, + 25, + { align: 'center' } + ); + } + + // Page number + pdf.setFontSize(8); + pdf.text( + `Page ${pageNum} of ${totalPages}`, + pageWidth - marginX, + pageHeight - 5, + { align: 'right' } + ); + }; + + // Helper function to convert hex color to RGB + const hexToRgb = (hex: string) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : { r: 24, g: 144, b: 255 }; // Default blue + }; + + // Helper function to add activity log item to PDF + const addActivityItem = (activity: IProjectActivityLog, yPosition: number) => { + const name = activity.done_by?.name || 'Unknown'; + const avatarBg = activity.done_by?.color_code || '#1890ff'; + const rgb = hexToRgb(avatarBg); + + // Draw avatar circle background + pdf.setFillColor(rgb.r, rgb.g, rgb.b); + pdf.circle(marginX + 3, yPosition + 2, 2.5, 'F'); + + // Add avatar text + pdf.setFontSize(9); + pdf.setTextColor(255, 255, 255); // White text + pdf.text( + name.charAt(0).toUpperCase(), + marginX + 3, + yPosition + 3, + { align: 'center' } + ); + + // Reset text color + pdf.setTextColor(0, 0, 0); + + // Add user name and action + pdf.setFontSize(10); + pdf.setFont('helvetica', 'bold'); + const userText = `${name} ${activity.log_text || ''}`; + const userLines = pdf.splitTextToSize(userText, contentWidth - 12); + pdf.text(userLines, marginX + 8, yPosition + 1); + + // Add task info + pdf.setFontSize(8); + pdf.setFont('helvetica', 'normal'); + const taskText = `${activity.task_key || ''} - ${activity.task_name || ''}`; + const taskLines = pdf.splitTextToSize(taskText, contentWidth - 12); + pdf.text(taskLines, marginX + 8, yPosition + 4); + + // Add timestamp + pdf.setFontSize(7); + pdf.setTextColor(100, 100, 100); // Gray text + const timeText = new Date(activity.created_at).toLocaleString(); + pdf.text(timeText, marginX + 8, yPosition + 7); + + // Reset text color + pdf.setTextColor(0, 0, 0); + + // Add separator line + pdf.setDrawColor(240, 240, 240); + pdf.line(marginX, yPosition + 10, pageWidth - marginX, yPosition + 10); + }; + + // Calculate total pages needed + const totalPages = Math.ceil(allLogs.length / itemsPerPage); + + // Process logs in chunks for each page + for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) { + if (pageIndex > 0) { + pdf.addPage(); + } + + // Add header to current page + addPageHeader(pageIndex + 1, totalPages); + + // Calculate logs for current page + const startIndex = pageIndex * itemsPerPage; + const endIndex = Math.min(startIndex + itemsPerPage, allLogs.length); + const pageActivityLogs = allLogs.slice(startIndex, endIndex); + + // Show progress message + if (pageIndex === 0) { + message.loading(`Generating PDF... Processing page ${pageIndex + 1} of ${totalPages}`, 0); + } else { + message.destroy(); + message.loading(`Generating PDF... Processing page ${pageIndex + 1} of ${totalPages}`, 0); + } + + // Add activity logs to current page + let currentY = marginY + 10; + pageActivityLogs.forEach((activity) => { + addActivityItem(activity, currentY); + currentY += itemHeight; + }); + + // Add summary at bottom of last page + if (pageIndex === totalPages - 1) { + pdf.setFontSize(10); + pdf.setFont('helvetica', 'italic'); + pdf.text( + `Total Activities: ${allLogs.length}`, + marginX, + pageHeight - 15 + ); + } + } + + message.destroy(); + message.success(`PDF generated successfully with ${totalPages} pages!`); + + // Save the PDF + const fileName = `Activity-Log-${projectName}-${format(new Date(), 'yyyy-MM-dd')}.pdf`; + pdf.save(fileName); + }; + + // Enhanced handleExportPdf function + const handleExportPdf = async () => { + if (!projectId) return; + + setExportLoading(true); + try { + // Fetch all activity logs for export (not just the currently loaded ones) + const allLogs = await fetchAllLogsForExport(); + + if (allLogs.length === 0) { + message.warning('No activity logs to export'); + return; + } + + await exportPdfWithAllLogs(projectId, allLogs); + } catch (error) { + console.error('PDF export failed:', error); + message.error('PDF export failed. Please try again.'); + } finally { + setExportLoading(false); + } + }; + + if (loading && logs.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+
+ Project Activity Log +
+ + +
+
+ + {error && ( +
+ Error: {error} +
+ )} + + {logs.length === 0 && !loading ? ( + + ) : ( +
+ +
+ )} + + {loadingMore && ( +
+ + + Loading more… + +
+ )} +
+ ); +}; + +export default ProjectViewActivityLog;