Compare commits

...

2 Commits

Author SHA1 Message Date
Chamika J
7f20353674 Merge pull request #220 from JayaruPerera/feature/project-activity-logs
feat: Add project activity logs API and frontend components
2025-07-11 11:27:52 +05:30
jayaruperera
bef69da0d1 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.
2025-07-02 10:36:35 +05:30
13 changed files with 1663 additions and 125 deletions

View File

@@ -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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
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<void> {
// ...keep your export logic as is...
}
}

View File

@@ -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<string,string> = {
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<IWorkLenzResponse> {
// 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;
}
// humanfriendly 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.")
);
}
}
}

View File

@@ -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;

View File

@@ -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 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);
api.use("/project-activity-logs", projectActivityLogsApiRouter);
export default api;

View File

@@ -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 healthcheck endpoint
projectActivityLogsApiRouter.get("/test", (req, res) => {
res.json({ message: "Project activity logs router is working!" });
});
export default projectActivityLogsApiRouter;

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<IServerResponse<IProjectActivityLogsResponse>> {
try {
const endpoint = `${API_BASE_URL}/project-activity-logs/project/${projectId}`;
const params: Record<string, any> = { 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<IProjectActivityLogsResponse>
>(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();

View File

@@ -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<string>) => {
state.filterType = action.payload;
state.currentPage = 1;
state.logs = [];
state.hasNextPage = true;
},
setCurrentPage: (state, action: PayloadAction<number>) => {
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;

View File

@@ -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),
},
];

View File

@@ -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<ActivityLogItemProps> = ({ 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 (
<Flex gap={4} align="center" style={{ fontSize: '12px' }}>
<Text code>{truncateText(activity.previous)}</Text>
<ArrowRightOutlined style={{ color: '#999' }} />
<Text code>{truncateText(activity.current)}</Text>
</Flex>
);
case 'status':
return (
<Flex gap={4} align="center">
<Tag color={activity.previous_status?.color_code || 'default'} style={{ margin: 0, fontSize: '11px' }}>
{activity.previous_status?.name || 'None'}
</Tag>
<ArrowRightOutlined style={{ color: '#999', fontSize: '10px' }} />
<Tag color={activity.next_status?.color_code || 'default'} style={{ margin: 0, fontSize: '11px' }}>
{activity.next_status?.name || 'None'}
</Tag>
</Flex>
);
case 'priority':
return (
<Flex gap={4} align="center">
<Tag color={activity.previous_priority?.color_code || 'default'} style={{ margin: 0, fontSize: '11px' }}>
{activity.previous_priority?.name || 'None'}
</Tag>
<ArrowRightOutlined style={{ color: '#999', fontSize: '10px' }} />
<Tag color={activity.next_priority?.color_code || 'default'} style={{ margin: 0, fontSize: '11px' }}>
{activity.next_priority?.name || 'None'}
</Tag>
</Flex>
);
case 'assignee':
if (activity.log_type === 'assign' && activity.assigned_user) {
return (
<Tag color={activity.assigned_user.color_code || '#1890ff'} style={{ margin: 0, fontSize: '11px' }}>
Assigned to {activity.assigned_user.name}
</Tag>
);
} else if (activity.log_type === 'unassign') {
return (
<Tag color="red" style={{ margin: 0, fontSize: '11px' }}>
Unassigned
</Tag>
);
}
break;
case 'estimation':
return (
<Flex gap={4} align="center" style={{ fontSize: '12px' }}>
<Text code>{activity.previous || '0m'}</Text>
<ArrowRightOutlined style={{ color: '#999' }} />
<Text code>{activity.current || '0m'}</Text>
</Flex>
);
case 'start date':
case 'end date':
return (
<Flex gap={4} align="center" style={{ fontSize: '12px' }}>
<Text code>{activity.previous || 'None'}</Text>
<ArrowRightOutlined style={{ color: '#999' }} />
<Text code>{activity.current || 'None'}</Text>
</Flex>
);
case 'description':
return (
<Text italic style={{ fontSize: '12px', color: '#666' }}>
Description updated
</Text>
);
default:
if (activity.previous && activity.current) {
return (
<Flex gap={4} align="center" style={{ fontSize: '12px' }}>
<Text code>{truncateText(activity.previous, 20)}</Text>
<ArrowRightOutlined style={{ color: '#999' }} />
<Text code>{truncateText(activity.current, 20)}</Text>
</Flex>
);
}
return null;
}
};
if (isLoading || !activity) {
return (
<div style={{ ...style, padding: '24px 16px',marginBottom: '20px', borderBottom: '1px solid #f0f0f0' }}>
<Timeline.Item>
<div style={{ minHeight: '80px' }}>
<Flex gap={12} align="flex-start">
<Skeleton.Avatar active size="default" />
<div style={{ flex: 1, minWidth: 0 }}>
<Skeleton active paragraph={{ rows: 2 }} title={{ width: '60%' }} />
</div>
</Flex>
</div>
</Timeline.Item>
</div>
);
}
return (
<div style={{ ...style, padding: '24px 16px',marginBottom: '20px', borderBottom: '1px solid #f0f0f0',borderRadius: '4px',boxShadow: '0 1px 2px rgba(0, 0, 0, 0.03)' }}>
<Timeline.Item>
<div style={{ minHeight: '80px' }}>
<Flex gap={12} align="flex-start">
<Avatar
src={activity.done_by?.avatar_url}
style={{
backgroundColor: activity.done_by?.color_code || '#1890ff',
color: 'white',
flexShrink: 0
}}
icon={!activity.done_by?.avatar_url && <UserOutlined />}
>
{!activity.done_by?.avatar_url && activity.done_by?.name?.charAt(0).toUpperCase()}
</Avatar>
<div style={{ flex: 1, minWidth: 0 }}>
<Flex gap={8} align="center" wrap style={{ marginBottom: '8px' }}>
<Text strong style={{
color: activity.done_by?.color_code || '#1890ff',
fontSize: '14px'
}}>
{activity.done_by?.name}
</Text>
<Text style={{ fontSize: '14px' }}>{activity.log_text}</Text>
</Flex>
<Flex gap={8} align="center" wrap style={{ marginBottom: '10px' }}>
<Tag color="blue" style={{
fontSize: '11px',
margin: 0,
padding: '2px 6px'
}}>
{activity.task_key}
</Tag>
<Text strong style={{
fontSize: '13px',
maxWidth: '300px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{activity.task_name}
</Text>
</Flex>
{renderAttributeValue(activity)}
<div style={{ marginTop: '10px' }}>
<Tooltip title={formatDateTime(activity.created_at)}>
<Text type="secondary" style={{ fontSize: '12px' }}>
{formatTimeAgo(activity.created_at)}
</Text>
</Tooltip>
</div>
</div>
</Flex>
</div>
</Timeline.Item>
</div>
);
};
export default ActivityLogItem;

View File

@@ -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<void>;
height: number;
itemHeight?: number;
}
const VirtualActivityList: React.FC<VirtualActivityListProps> = ({
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 (
<div style={{ ...style, display: 'flex', justifyContent: 'center', alignItems: 'center',padding: '20px 0' }}>
<Spin size="small" />
<Text type="secondary" style={{ marginLeft: 8 }}>
Loading
</Text>
</div>
);
}
const log = logs[index];
return (
<div
style={{
...style,
padding: '8px 16px', // Reduced from 12px to 8px
borderBottom: '1px solid #f0f0f0',
boxSizing: 'border-box',
display: 'flex',
}}
key={log.id}
>
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: log.done_by?.color_code || '#1890ff',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 14,
flexShrink: 0,
}}
>
{log.done_by?.name?.charAt(0).toUpperCase() || 'U'}
</div>
<div style={{ marginLeft: 12, flex: 1 }}>
<div style={{ fontWeight: 500, marginBottom: 2 }}>
{log.done_by?.name || 'Unknown'}{' '}
<Text type="secondary">{log.log_text}</Text>
</div>
<div style={{ fontSize: 13, marginBottom: 3 }}>
<Text code>{log.task_key}</Text>{' '}
<Text strong>{log.task_name}</Text>
</div>
<div style={{ fontSize: 12, color: '#666' }}>
{new Date(log.created_at).toLocaleString()}
</div>
</div>
</div>
);
};
return (
<InfiniteLoader
isItemLoaded={isItemLoaded}
itemCount={itemCount}
loadMoreItems={loadMoreItems}
threshold={3} // start loading when 3 items from bottom
>
{({ onItemsRendered, ref }) => (
<List
height={height}
itemCount={itemCount}
itemSize={itemHeight}
onItemsRendered={onItemsRendered}
ref={ref}
width="100%"
>
{renderRow}
</List>
)}
</InfiniteLoader>
);
};
export default VirtualActivityList;

View File

@@ -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<HTMLDivElement>(null);
// Data + paging
const [logs, setLogs] = useState<IProjectActivityLog[]>([]);
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<string | null>(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<IProjectActivityLog[]> => {
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 (
<div style={{ textAlign: 'center', paddingTop: 100 }}>
<Spin size="large" />
</div>
);
}
return (
<div style={{ padding: 20, height: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={4}>Project Activity Log</Title>
<div style={{ display: 'flex', gap: 12 }}>
<Select
value={filterType}
onChange={onFilterChange}
style={{ width: 200 }}
suffixIcon={<FilterOutlined />}
loading={loading}
>
{projectActivityLogsApiService.getFilterOptions().map(o => (
<Option key={o.value} value={o.value}>
{o.label}
</Option>
))}
</Select>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleExportPdf}
loading={exportLoading}
disabled={logs.length === 0}
>
Export PDF
</Button>
</div>
</div>
{error && (
<div style={{ color: 'red', textAlign: 'center', marginBottom: 16 }}>
Error: {error}
</div>
)}
{logs.length === 0 && !loading ? (
<Empty
description={`No ${
filterType === 'all' ? '' : filterType + ' '
}activity logs found`}
style={{ marginTop: 50 }}
/>
) : (
<div ref={exportRef} style={{ flex: 1 }}>
<VirtualActivityList
logs={logs}
hasNextPage={hasNextPage}
isItemLoaded={isItemLoaded}
loadMoreItems={loadMoreItems}
height={listHeight}
/>
</div>
)}
{loadingMore && (
<div
style={{
textAlign: 'center',
padding: 20,
borderTop: '1px solid #eee',
}}
>
<Spin size="small" />
<Text type="secondary" style={{ marginLeft: 8 }}>
Loading more
</Text>
</div>
)}
</div>
);
};
export default ProjectViewActivityLog;