From ccb8e68192b91dd96df977e4e76d8ace6a356270 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 13 Jun 2025 09:50:43 +0530 Subject: [PATCH] feat(reporting): add billable and non-billable time tracking to member reports - Implemented SQL logic to calculate billable and non-billable time for team members in the reporting module. - Enhanced the reporting members table to display new time tracking metrics with appropriate headers and tooltips. - Created a new TimeLogsCell component to visually represent billable vs non-billable time with percentage breakdowns. - Updated localization files for English, Spanish, and Portuguese to include new terms related to time tracking. --- .../reporting/reporting-members-controller.ts | 55 ++++++++++++- .../public/locales/en/reporting-members.json | 7 +- .../public/locales/es/reporting-members.json | 7 +- .../public/locales/pt/reporting-members.json | 7 +- .../members-reports-table.tsx | 10 +++ .../tablesCells/timeLogsCell/TimeLogsCell.tsx | 79 +++++++++++++++++++ .../src/types/reporting/reporting.types.ts | 2 + 7 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/tablesCells/timeLogsCell/TimeLogsCell.tsx diff --git a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts index 97500437..44050265 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts @@ -31,6 +31,7 @@ export default class ReportingMembersController extends ReportingControllerBase const completedDurationClasue = this.completedDurationFilter(key, dateRange); const overdueActivityLogsClause = this.getActivityLogsOverdue(key, dateRange); const activityLogCreationFilter = this.getActivityLogsCreationClause(key, dateRange); + const timeLogDateRangeClause = this.getTimeLogDateRangeClause(key, dateRange); const q = `SELECT COUNT(DISTINCT email) AS total, (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) @@ -100,7 +101,25 @@ export default class ReportingMembersController extends ReportingControllerBase FROM tasks t LEFT JOIN tasks_assignees ta ON t.id = ta.task_id WHERE team_member_id = tmiv.team_member_id - AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs + AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs, + + (SELECT COALESCE(SUM(twl.time_spent), 0) + FROM task_work_log twl + LEFT JOIN tasks t ON twl.task_id = t.id + WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id) + AND t.billable IS TRUE + AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1) + ${timeLogDateRangeClause} + ${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS billable_time, + + (SELECT COALESCE(SUM(twl.time_spent), 0) + FROM task_work_log twl + LEFT JOIN tasks t ON twl.task_id = t.id + WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id) + AND t.billable IS FALSE + AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1) + ${timeLogDateRangeClause} + ${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS non_billable_time FROM team_member_info_view tmiv WHERE tmiv.team_id = $1 ${teamsClause} AND tmiv.team_member_id IN (SELECT team_member_id @@ -311,6 +330,30 @@ export default class ReportingMembersController extends ReportingControllerBase return ""; } + protected static getTimeLogDateRangeClause(key: string, dateRange: string[]) { + if (dateRange.length === 2) { + const start = moment(dateRange[0]).format("YYYY-MM-DD"); + const end = moment(dateRange[1]).format("YYYY-MM-DD"); + + if (start === end) { + return `AND twl.created_at::DATE = '${start}'::DATE`; + } + + return `AND twl.created_at::DATE >= '${start}'::DATE AND twl.created_at < '${end}'::DATE + INTERVAL '1 day'`; + } + + if (key === DATE_RANGES.YESTERDAY) + return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND twl.created_at < CURRENT_DATE::DATE`; + if (key === DATE_RANGES.LAST_WEEK) + return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`; + if (key === DATE_RANGES.LAST_MONTH) + return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`; + if (key === DATE_RANGES.LAST_QUARTER) + return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`; + + return ""; + } + private static formatDuration(duration: moment.Duration) { const empty = "0h 0m"; let format = ""; @@ -423,6 +466,8 @@ export default class ReportingMembersController extends ReportingControllerBase { header: "Overdue Tasks", key: "overdue_tasks", width: 20 }, { header: "Completed Tasks", key: "completed_tasks", width: 20 }, { header: "Ongoing Tasks", key: "ongoing_tasks", width: 20 }, + { header: "Billable Time (seconds)", key: "billable_time", width: 25 }, + { header: "Non-Billable Time (seconds)", key: "non_billable_time", width: 25 }, { header: "Done Tasks(%)", key: "done_tasks", width: 20 }, { header: "Doing Tasks(%)", key: "doing_tasks", width: 20 }, { header: "Todo Tasks(%)", key: "todo_tasks", width: 20 } @@ -430,14 +475,14 @@ export default class ReportingMembersController extends ReportingControllerBase // set title sheet.getCell("A1").value = `Members from ${teamName}`; - sheet.mergeCells("A1:K1"); + sheet.mergeCells("A1:M1"); sheet.getCell("A1").alignment = { horizontal: "center" }; sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } }; sheet.getCell("A1").font = { size: 16 }; // set export date sheet.getCell("A2").value = `Exported on : ${exportDate}`; - sheet.mergeCells("A2:K2"); + sheet.mergeCells("A2:M2"); sheet.getCell("A2").alignment = { horizontal: "center" }; sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } }; sheet.getCell("A2").font = { size: 12 }; @@ -447,7 +492,7 @@ export default class ReportingMembersController extends ReportingControllerBase sheet.mergeCells("A3:D3"); // set table headers - sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"]; + sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Billable Time (seconds)", "Non-Billable Time (seconds)", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"]; sheet.getRow(5).font = { bold: true }; for (const member of result.members) { @@ -458,6 +503,8 @@ export default class ReportingMembersController extends ReportingControllerBase overdue_tasks: member.overdue, completed_tasks: member.completed, ongoing_tasks: member.ongoing, + billable_time: member.billable_time || 0, + non_billable_time: member.non_billable_time || 0, done_tasks: member.completed, doing_tasks: member.ongoing_by_activity_logs, todo_tasks: member.todo_by_activity_logs diff --git a/worklenz-frontend/public/locales/en/reporting-members.json b/worklenz-frontend/public/locales/en/reporting-members.json index a8035dcd..637decfc 100644 --- a/worklenz-frontend/public/locales/en/reporting-members.json +++ b/worklenz-frontend/public/locales/en/reporting-members.json @@ -31,5 +31,10 @@ "todoText": "To Do", "doingText": "Doing", - "doneText": "Done" + "doneText": "Done", + + "timeLogsColumn": "Time Logs", + "timeLogsColumnTooltip": "Shows the proportion of billable vs non-billable time", + "billable": "Billable", + "nonBillable": "Non-Billable" } diff --git a/worklenz-frontend/public/locales/es/reporting-members.json b/worklenz-frontend/public/locales/es/reporting-members.json index d87cafb8..73887b7e 100644 --- a/worklenz-frontend/public/locales/es/reporting-members.json +++ b/worklenz-frontend/public/locales/es/reporting-members.json @@ -31,5 +31,10 @@ "todoText": "Por Hacer", "doingText": "Haciendo", - "doneText": "Hecho" + "doneText": "Hecho", + + "timeLogsColumn": "Registros de Tiempo", + "timeLogsColumnTooltip": "Muestra la proporción de tiempo facturable vs no facturable", + "billable": "Facturable", + "nonBillable": "No Facturable" } diff --git a/worklenz-frontend/public/locales/pt/reporting-members.json b/worklenz-frontend/public/locales/pt/reporting-members.json index a8035dcd..dfc68e89 100644 --- a/worklenz-frontend/public/locales/pt/reporting-members.json +++ b/worklenz-frontend/public/locales/pt/reporting-members.json @@ -31,5 +31,10 @@ "todoText": "To Do", "doingText": "Doing", - "doneText": "Done" + "doneText": "Done", + + "timeLogsColumn": "Registros de Tempo", + "timeLogsColumnTooltip": "Mostra a proporção de tempo faturável vs não faturável", + "billable": "Faturável", + "nonBillable": "Não Faturável" } diff --git a/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/members-reports-table.tsx b/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/members-reports-table.tsx index 26b26112..3658a7e3 100644 --- a/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/members-reports-table.tsx +++ b/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/members-reports-table.tsx @@ -6,6 +6,7 @@ import { useAppDispatch } from '@/hooks/useAppDispatch'; import CustomTableTitle from '@/components/CustomTableTitle'; import TasksProgressCell from './tablesCells/tasksProgressCell/TasksProgressCell'; import MemberCell from './tablesCells/memberCell/MemberCell'; +import TimeLogsCell from './tablesCells/timeLogsCell/TimeLogsCell'; import { fetchMembersData, setPagination, @@ -54,6 +55,15 @@ const MembersReportsTable = () => { return todo || doing || done ? : '-'; }, }, + { + key: 'timeLogs', + title: , + render: record => { + const billableTime = record.billable_time || 0; + const nonBillableTime = record.non_billable_time || 0; + return ; + }, + }, { key: 'tasksAssigned', title: ( diff --git a/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/tablesCells/timeLogsCell/TimeLogsCell.tsx b/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/tablesCells/timeLogsCell/TimeLogsCell.tsx new file mode 100644 index 00000000..84a10d5e --- /dev/null +++ b/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/tablesCells/timeLogsCell/TimeLogsCell.tsx @@ -0,0 +1,79 @@ +import { Tooltip } from 'antd'; +import { useTranslation } from 'react-i18next'; + +interface TimeLogsCellProps { + billableTime: number; + nonBillableTime: number; +} + +const TimeLogsCell = ({ billableTime, nonBillableTime }: TimeLogsCellProps) => { + const { t } = useTranslation('reporting-members'); + const totalTime = billableTime + nonBillableTime; + + if (totalTime === 0) return '-'; + + const billablePercentage = Math.round((billableTime / totalTime) * 100); + const nonBillablePercentage = 100 - billablePercentage; + + // Ensure minimum visibility for very small percentages + const minWidth = 2; // minimum 2% width for visibility + const billableWidth = Math.max(billablePercentage, billablePercentage > 0 ? minWidth : 0); + const nonBillableWidth = Math.max(nonBillablePercentage, nonBillablePercentage > 0 ? minWidth : 0); + + // Format time in hours and minutes + const formatTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; + }; + + const tooltipContent = ( +
+
+
+ {t('billable')}: {formatTime(billableTime)} ({billablePercentage}%) +
+
+
+ {t('nonBillable')}: {formatTime(nonBillableTime)} ({nonBillablePercentage}%) +
+
+ Total: {formatTime(totalTime)} +
+
+ ); + + return ( + +
+
+ {/* Billable time section (green) */} + {billableTime > 0 && ( +
+ )} + {/* Non-billable time section (gray) */} + {nonBillableTime > 0 && ( +
+ )} + {/* Percentage text overlay */} +
+ + {billablePercentage}% + +
+
+
+ + ); +}; + +export default TimeLogsCell; \ No newline at end of file diff --git a/worklenz-frontend/src/types/reporting/reporting.types.ts b/worklenz-frontend/src/types/reporting/reporting.types.ts index a0ff7bf7..ebebbdf4 100644 --- a/worklenz-frontend/src/types/reporting/reporting.types.ts +++ b/worklenz-frontend/src/types/reporting/reporting.types.ts @@ -136,6 +136,8 @@ export interface IRPTMember { ongoing: number; todo: number; member_teams: any; + billable_time?: number; + non_billable_time?: number; } export interface ISingleMemberLogs {