Files
worklenz/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts
chamikaJ fc30c1854e feat(reporting): add support for 'all time' date range in reporting allocation
- Implemented logic to fetch the earliest start date from selected projects when the 'all time' duration is specified.
- Updated the start date to default to January 1, 2000 if no valid date is found, ensuring robust date handling in reports.
2025-05-19 12:35:32 +05:30

640 lines
25 KiB
TypeScript

import moment from "moment";
import db from "../../config/db";
import HandleExceptions from "../../decorators/handle-exceptions";
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
import { ServerResponse } from "../../models/server-response";
import { getColor, int, log_error } from "../../shared/utils";
import ReportingControllerBase from "./reporting-controller-base";
import { DATE_RANGES } from "../../shared/constants";
import Excel from "exceljs";
import ChartJsImage from "chartjs-to-image";
enum IToggleOptions {
'WORKING_DAYS' = 'WORKING_DAYS', 'MAN_DAYS' = 'MAN_DAYS'
}
export default class ReportingAllocationController extends ReportingControllerBase {
private static async getTimeLoggedByProjects(projects: string[], users: string[], key: string, dateRange: string[], archived = false, user_id = "", billable: { billable: boolean; nonBillable: boolean }): Promise<any> {
try {
const projectIds = projects.map(p => `'${p}'`).join(",");
const userIds = users.map(u => `'${u}'`).join(",");
const duration = this.getDateRangeClause(key || DATE_RANGES.LAST_WEEK, dateRange);
const archivedClause = archived
? ""
: `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND user_id = '${user_id}') `;
const billableQuery = this.buildBillableQuery(billable);
const projectTimeLogs = await this.getTotalTimeLogsByProject(archived, duration, projectIds, userIds, archivedClause, billableQuery);
const userTimeLogs = await this.getTotalTimeLogsByUser(archived, duration, projectIds, userIds, billableQuery);
const format = (seconds: number) => {
if (seconds === 0) return "-";
const duration = moment.duration(seconds, "seconds");
const formattedDuration = `${~~(duration.asHours())}h ${duration.minutes()}m ${duration.seconds()}s`;
return formattedDuration;
};
let totalProjectsTime = 0;
let totalUsersTime = 0;
for (const project of projectTimeLogs) {
if (project.all_tasks_count > 0) {
project.progress = Math.round((project.completed_tasks_count / project.all_tasks_count) * 100);
} else {
project.progress = 0;
}
let total = 0;
for (const log of project.time_logs) {
total += log.time_logged;
log.time_logged = format(log.time_logged);
}
project.totalProjectsTime = totalProjectsTime + total;
project.total = format(total);
}
for (const log of userTimeLogs) {
log.totalUsersTime = totalUsersTime + parseInt(log.time_logged)
log.time_logged = format(parseInt(log.time_logged));
}
return { projectTimeLogs, userTimeLogs };
} catch (error) {
log_error(error);
}
return [];
}
private static async getTotalTimeLogsByProject(archived: boolean, duration: string, projectIds: string, userIds: string, archivedClause = "", billableQuery = '') {
try {
const q = `SELECT projects.name,
projects.color_code,
sps.name AS status_name,
sps.color_code AS status_color_code,
sps.icon AS status_icon,
(SELECT COUNT(*)
FROM tasks
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END ${billableQuery}
AND project_id = projects.id) AS all_tasks_count,
(SELECT COUNT(*)
FROM tasks
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
AND project_id = projects.id ${billableQuery}
AND status_id IN (SELECT id
FROM task_statuses
WHERE project_id = projects.id
AND category_id IN
(SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count,
(
SELECT COALESCE(JSON_AGG(r), '[]'::JSON)
FROM (
SELECT name,
(SELECT COALESCE(SUM(time_spent), 0)
FROM task_work_log
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
WHERE user_id = users.id ${billableQuery}
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
AND tasks.project_id = projects.id
${duration}) AS time_logged
FROM users
WHERE id IN (${userIds})
ORDER BY name
) r
) AS time_logs
FROM projects
LEFT JOIN sys_project_statuses sps ON projects.status_id = sps.id
WHERE projects.id IN (${projectIds}) ${archivedClause};`;
const result = await db.query(q, [archived]);
return result.rows;
} catch (error) {
log_error(error);
return [];
}
}
private static async getTotalTimeLogsByUser(archived: boolean, duration: string, projectIds: string, userIds: string, billableQuery = "") {
try {
const q = `(SELECT id,
(SELECT COALESCE(SUM(time_spent), 0)
FROM task_work_log
LEFT JOIN tasks ON task_work_log.task_id = tasks.id ${billableQuery}
WHERE user_id = users.id
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
AND tasks.project_id IN (${projectIds})
${duration}) AS time_logged
FROM users
WHERE id IN (${userIds})
ORDER BY name);`;
const result = await db.query(q, [archived]);
return result.rows;
} catch (error) {
log_error(error);
return [];
}
}
private static async getUserIds(teamIds: any) {
try {
const q = `SELECT id, (SELECT name)
FROM users
WHERE id IN (SELECT user_id
FROM team_members
WHERE team_id IN (${teamIds}))
GROUP BY id
ORDER BY name`;
const result = await db.query(q, []);
return result.rows;
} catch (error) {
log_error(error);
return [];
}
}
@HandleExceptions()
public static async getAllocation(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const teams = (req.body.teams || []) as string[]; // ids
const billable = req.body.billable;
const teamIds = teams.map(id => `'${id}'`).join(",");
const projectIds = (req.body.projects || []) as string[];
if (!teamIds || !projectIds.length)
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
const users = await this.getUserIds(teamIds);
const userIds = users.map((u: any) => u.id);
const { projectTimeLogs, userTimeLogs } = await this.getTimeLoggedByProjects(projectIds, userIds, req.body.duration, req.body.date_range, (req.query.archived === "true"), req.user?.id, billable);
for (const [i, user] of users.entries()) {
user.total_time = userTimeLogs[i].time_logged;
}
return res.status(200).send(new ServerResponse(true, { users, projects: projectTimeLogs }));
}
public static formatDurationDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
@HandleExceptions()
public static async export(req: IWorkLenzRequest, res: IWorkLenzResponse) {
const teams = (req.query.teams as string)?.split(",");
const teamIds = teams.map(t => `'${t}'`).join(",");
const billable = req.body.billable ? req.body.billable : { billable: req.query.billable === "true", nonBillable: req.query.nonBillable === "true" };
const projectIds = (req.query.projects as string)?.split(",");
const duration = req.query.duration;
const dateRange = (req.query.date_range as string)?.split(",");
let start = "-";
let end = "-";
if (dateRange.length === 2) {
start = dateRange[0] ? this.formatDurationDate(new Date(dateRange[0])).toString() : "-";
end = dateRange[1] ? this.formatDurationDate(new Date(dateRange[1])).toString() : "-";
} else {
switch (duration) {
case DATE_RANGES.YESTERDAY:
start = moment().subtract(1, "day").format("YYYY-MM-DD").toString();
break;
case DATE_RANGES.LAST_WEEK:
start = moment().subtract(1, "week").format("YYYY-MM-DD").toString();
break;
case DATE_RANGES.LAST_MONTH:
start = moment().subtract(1, "month").format("YYYY-MM-DD").toString();
break;
case DATE_RANGES.LAST_QUARTER:
start = moment().subtract(3, "months").format("YYYY-MM-DD").toString();
break;
}
end = moment().format("YYYY-MM-DD").toString();
}
const users = await this.getUserIds(teamIds);
const userIds = users.map((u: any) => u.id);
const { projectTimeLogs, userTimeLogs } = await this.getTimeLoggedByProjects(projectIds, userIds, duration as string, dateRange, (req.query.include_archived === "true"), req.user?.id, billable);
for (const [i, user] of users.entries()) {
user.total_time = userTimeLogs[i].time_logged;
}
// excel file
const exportDate = moment().format("MMM-DD-YYYY");
const fileName = `Reporting Time Sheet - ${exportDate}`;
const workbook = new Excel.Workbook();
const sheet = workbook.addWorksheet("Reporting Time Sheet");
sheet.columns = [
{ header: "Project", key: "project", width: 25 },
{ header: "Logged Time", key: "logged_time", width: 20 },
{ header: "Total", key: "total", width: 25 },
];
sheet.getCell("A1").value = `Reporting Time Sheet`;
sheet.mergeCells("A1:G1");
sheet.getCell("A1").alignment = { horizontal: "center" };
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
sheet.getCell("A1").font = { size: 16 };
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
sheet.mergeCells("A2:G2");
sheet.getCell("A2").alignment = { horizontal: "center" };
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
sheet.getCell("A2").font = { size: 12 };
// set duration
sheet.getCell("A3").value = `From : ${start} To : ${end}`;
sheet.mergeCells("A3:D3");
let totalProjectTime = 0;
let totalMemberTime = 0;
if (projectTimeLogs.length > 0) {
const rowTop = sheet.getRow(5);
rowTop.getCell(1).value = "";
users.forEach((user: { id: string, name: string, total_time: string }, index: any) => {
rowTop.getCell(index + 2).value = user.name;
});
rowTop.getCell(users.length + 2).value = "Total";
rowTop.font = {
bold: true
};
for (const project of projectTimeLogs) {
const rowValues = [];
rowValues[1] = project.name;
project.time_logs.forEach((log: any, index: any) => {
rowValues[index + 2] = log.time_logged === "0h 0m 0s" ? "-" : log.time_logged;
});
rowValues[project.time_logs.length + 2] = project.total;
sheet.addRow(rowValues);
const { lastRow } = sheet;
if (lastRow) {
const totalCell = lastRow.getCell(project.time_logs.length + 2);
totalCell.style.font = { bold: true };
}
totalProjectTime = totalProjectTime + project.totalProjectsTime
}
const rowBottom = sheet.getRow(projectTimeLogs.length + 6);
rowBottom.getCell(1).value = "Total";
rowBottom.getCell(1).style.font = { bold: true };
userTimeLogs.forEach((log: { id: string, time_logged: string, totalUsersTime: number }, index: any) => {
totalMemberTime = totalMemberTime + log.totalUsersTime
rowBottom.getCell(index + 2).value = log.time_logged;
});
rowBottom.font = {
bold: true
};
}
const format = (seconds: number) => {
if (seconds === 0) return "-";
const duration = moment.duration(seconds, "seconds");
const formattedDuration = `${~~(duration.asHours())}h ${duration.minutes()}m ${duration.seconds()}s`;
return formattedDuration;
};
const projectTotalTimeRow = sheet.getRow(projectTimeLogs.length + 8);
projectTotalTimeRow.getCell(1).value = "Total logged time of Projects"
projectTotalTimeRow.getCell(2).value = `${format(totalProjectTime)}`
projectTotalTimeRow.getCell(1).style.font = { bold: true };
projectTotalTimeRow.getCell(2).style.font = { bold: true };
const membersTotalTimeRow = sheet.getRow(projectTimeLogs.length + 9);
membersTotalTimeRow.getCell(1).value = "Total logged time of Members"
membersTotalTimeRow.getCell(2).value = `${format(totalMemberTime)}`
membersTotalTimeRow.getCell(1).style.font = { bold: true };
membersTotalTimeRow.getCell(2).style.font = { bold: true };
res.setHeader("Content-Type", "application/vnd.openxmlformats");
res.setHeader("Content-Disposition", `attachment; filename=${fileName}.xlsx`);
await workbook.xlsx.write(res)
.then(() => {
res.end();
});
}
@HandleExceptions()
public static async getProjectTimeSheets(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const archived = req.query.archived === "true";
const teams = (req.body.teams || []) as string[]; // ids
const teamIds = teams.map(id => `'${id}'`).join(",");
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const billable = req.body.billable;
if (!teamIds || !projectIds.length)
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
const { duration, date_range } = req.body;
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
const archivedClause = archived
? ""
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
const billableQuery = this.buildBillableQuery(billable);
const q = `
SELECT p.id,
p.name,
(SELECT SUM(time_spent)) AS logged_time,
SUM(total_minutes) AS estimated,
color_code
FROM projects p
LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery}
LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
GROUP BY p.id, p.name
ORDER BY logged_time DESC;`;
const result = await db.query(q, []);
const data = [];
for (const project of result.rows) {
project.value = project.logged_time ? parseFloat(moment.duration(project.logged_time, "seconds").asHours().toFixed(2)) : 0;
project.estimated_value = project.estimated ? parseFloat(moment.duration(project.estimated, "minutes").asHours().toFixed(2)) : 0;
if (project.value > 0) {
data.push(project);
}
}
return res.status(200).send(new ServerResponse(true, data));
}
@HandleExceptions()
public static async getMemberTimeSheets(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const archived = req.query.archived === "true";
const teams = (req.body.teams || []) as string[]; // ids
const teamIds = teams.map(id => `'${id}'`).join(",");
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const billable = req.body.billable;
if (!teamIds || !projectIds.length)
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
const { duration, date_range } = req.body;
// Calculate the date range (start and end)
let startDate: moment.Moment;
let endDate: moment.Moment;
if (date_range && date_range.length === 2) {
startDate = moment(date_range[0]);
endDate = moment(date_range[1]);
} else if (duration === DATE_RANGES.ALL_TIME) {
// Fetch the earliest start_date (or created_at if null) from selected projects
const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`;
const minDateResult = await db.query(minDateQuery, []);
const minDate = minDateResult.rows[0]?.min_date;
startDate = minDate ? moment(minDate) : moment('2000-01-01');
endDate = moment();
} else {
switch (duration) {
case DATE_RANGES.YESTERDAY:
startDate = moment().subtract(1, "day");
endDate = moment().subtract(1, "day");
break;
case DATE_RANGES.LAST_WEEK:
startDate = moment().subtract(1, "week").startOf("isoWeek");
endDate = moment().subtract(1, "week").endOf("isoWeek");
break;
case DATE_RANGES.LAST_MONTH:
startDate = moment().subtract(1, "month").startOf("month");
endDate = moment().subtract(1, "month").endOf("month");
break;
case DATE_RANGES.LAST_QUARTER:
startDate = moment().subtract(3, "months").startOf("quarter");
endDate = moment().subtract(1, "quarter").endOf("quarter");
break;
default:
startDate = moment().startOf("day");
endDate = moment().endOf("day");
}
}
// Count only weekdays (Mon-Fri) in the period
let workingDays = 0;
let current = startDate.clone();
while (current.isSameOrBefore(endDate, 'day')) {
const day = current.isoWeekday();
if (day >= 1 && day <= 5) workingDays++;
current.add(1, 'day');
}
// Get hours_per_day for all selected projects
const projectHoursQuery = `SELECT id, hours_per_day FROM projects WHERE id IN (${projectIds})`;
const projectHoursResult = await db.query(projectHoursQuery, []);
const projectHoursMap: Record<string, number> = {};
for (const row of projectHoursResult.rows) {
projectHoursMap[row.id] = row.hours_per_day || 8;
}
// Sum total working hours for all selected projects
let totalWorkingHours = 0;
for (const pid of Object.keys(projectHoursMap)) {
totalWorkingHours += workingDays * projectHoursMap[pid];
}
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
const archivedClause = archived
? ""
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
const billableQuery = this.buildBillableQuery(billable);
const q = `
SELECT tmiv.email, tmiv.name, SUM(time_spent) AS logged_time
FROM team_member_info_view tmiv
LEFT JOIN task_work_log ON task_work_log.user_id = tmiv.user_id
LEFT JOIN tasks ON tasks.id = task_work_log.task_id ${billableQuery}
LEFT JOIN projects p ON p.id = tasks.project_id AND p.team_id = tmiv.team_id
WHERE p.id IN (${projectIds})
${durationClause} ${archivedClause}
GROUP BY tmiv.email, tmiv.name
ORDER BY logged_time DESC;`;
const result = await db.query(q, []);
for (const member of result.rows) {
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
member.color_code = getColor(member.name);
member.total_working_hours = totalWorkingHours;
member.utilization_percent = (totalWorkingHours > 0 && member.logged_time) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00';
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
// Over/under utilized hours: utilized_hours - total_working_hours
const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0;
member.over_under_utilized_hours = overUnder.toFixed(2);
}
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async exportTest(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const archived = req.query.archived === "true";
const teamId = this.getCurrentTeamId(req);
const { duration, date_range } = req.query;
const durationClause = this.getDateRangeClause(duration as string || DATE_RANGES.LAST_WEEK, date_range as string[]);
const archivedClause = archived
? ""
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
const q = `
SELECT p.id,
p.name,
(SELECT SUM(time_spent)) AS logged_time,
SUM(total_minutes) AS estimated,
color_code
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
LEFT JOIN task_work_log ON task_work_log.task_id = t.id
WHERE in_organization(p.team_id, $1)
${durationClause} ${archivedClause}
GROUP BY p.id, p.name
ORDER BY p.name ASC;`;
const result = await db.query(q, [teamId]);
const labelsX = [];
const dataX = [];
for (const project of result.rows) {
project.value = project.logged_time ? parseFloat(moment.duration(project.logged_time, "seconds").asHours().toFixed(2)) : 0;
project.estimated_value = project.estimated ? parseFloat(moment.duration(project.estimated, "minutes").asHours().toFixed(2)) : 0;
labelsX.push(project.name);
dataX.push(project.value || 0);
}
const chart = new ChartJsImage();
chart.setConfig({
type: "bar",
data: {
labels: labelsX,
datasets: [
{ label: "", data: dataX }
]
},
});
chart.setWidth(1920).setHeight(1080).setBackgroundColor("transparent");
const url = chart.getUrl();
chart.toFile("test.png");
return res.status(200).send(new ServerResponse(true, url));
}
private static getEstimated(project: any, type: string) {
// if (project.estimated_man_days === 0 || project.estimated_working_days === 0) {
// return (parseFloat(moment.duration(project.estimated, "minutes").asHours().toFixed(2)) / int(project.hours_per_day)).toFixed(2)
// }
switch (type) {
case IToggleOptions.MAN_DAYS:
return project.estimated_man_days ?? 0;;
case IToggleOptions.WORKING_DAYS:
return project.estimated_working_days ?? 0;;
default:
return 0;
}
}
@HandleExceptions()
public static async getEstimatedVsActual(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const archived = req.query.archived === "true";
const teams = (req.body.teams || []) as string[]; // ids
const teamIds = teams.map(id => `'${id}'`).join(",");
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const { type, billable } = req.body;
if (!teamIds || !projectIds.length)
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
const { duration, date_range } = req.body;
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
const archivedClause = archived
? ""
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
const billableQuery = this.buildBillableQuery(billable);
const q = `
SELECT p.id,
p.name,
p.end_date,
p.hours_per_day::INT,
p.estimated_man_days::INT,
p.estimated_working_days::INT,
(SELECT SUM(time_spent)) AS logged_time,
(SELECT COALESCE(SUM(total_minutes), 0)
FROM tasks
WHERE project_id = p.id) AS estimated,
color_code
FROM projects p
LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery}
LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
GROUP BY p.id, p.name
ORDER BY logged_time DESC;`;
const result = await db.query(q, []);
const data = [];
for (const project of result.rows) {
const durationInHours = parseFloat(moment.duration(project.logged_time, "seconds").asHours().toFixed(2));
const hoursPerDay = parseInt(project.hours_per_day ?? 1);
project.value = parseFloat((durationInHours / hoursPerDay).toFixed(2)) ?? 0;
project.estimated_value = this.getEstimated(project, type);
project.estimated_man_days = project.estimated_man_days ?? 0;
project.estimated_working_days = project.estimated_working_days ?? 0;
project.hours_per_day = project.hours_per_day ?? 0;
if (project.value > 0 || project.estimated_value > 0) {
data.push(project);
}
}
return res.status(200).send(new ServerResponse(true, data));
}
}