Compare commits
2 Commits
main
...
feature/pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f20353674 | ||
|
|
bef69da0d1 |
@@ -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...
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
// 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.")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
22
worklenz-frontend/package-lock.json
generated
22
worklenz-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user