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.
This commit is contained in:
@@ -31,6 +31,7 @@ export default class ReportingMembersController extends ReportingControllerBase
|
|||||||
const completedDurationClasue = this.completedDurationFilter(key, dateRange);
|
const completedDurationClasue = this.completedDurationFilter(key, dateRange);
|
||||||
const overdueActivityLogsClause = this.getActivityLogsOverdue(key, dateRange);
|
const overdueActivityLogsClause = this.getActivityLogsOverdue(key, dateRange);
|
||||||
const activityLogCreationFilter = this.getActivityLogsCreationClause(key, dateRange);
|
const activityLogCreationFilter = this.getActivityLogsCreationClause(key, dateRange);
|
||||||
|
const timeLogDateRangeClause = this.getTimeLogDateRangeClause(key, dateRange);
|
||||||
|
|
||||||
const q = `SELECT COUNT(DISTINCT email) AS total,
|
const q = `SELECT COUNT(DISTINCT email) AS total,
|
||||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
(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
|
FROM tasks t
|
||||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||||
WHERE team_member_id = tmiv.team_member_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
|
FROM team_member_info_view tmiv
|
||||||
WHERE tmiv.team_id = $1 ${teamsClause}
|
WHERE tmiv.team_id = $1 ${teamsClause}
|
||||||
AND tmiv.team_member_id IN (SELECT team_member_id
|
AND tmiv.team_member_id IN (SELECT team_member_id
|
||||||
@@ -311,6 +330,30 @@ export default class ReportingMembersController extends ReportingControllerBase
|
|||||||
return "";
|
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) {
|
private static formatDuration(duration: moment.Duration) {
|
||||||
const empty = "0h 0m";
|
const empty = "0h 0m";
|
||||||
let format = "";
|
let format = "";
|
||||||
@@ -423,6 +466,8 @@ export default class ReportingMembersController extends ReportingControllerBase
|
|||||||
{ header: "Overdue Tasks", key: "overdue_tasks", width: 20 },
|
{ header: "Overdue Tasks", key: "overdue_tasks", width: 20 },
|
||||||
{ header: "Completed Tasks", key: "completed_tasks", width: 20 },
|
{ header: "Completed Tasks", key: "completed_tasks", width: 20 },
|
||||||
{ header: "Ongoing Tasks", key: "ongoing_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: "Done Tasks(%)", key: "done_tasks", width: 20 },
|
||||||
{ header: "Doing Tasks(%)", key: "doing_tasks", width: 20 },
|
{ header: "Doing Tasks(%)", key: "doing_tasks", width: 20 },
|
||||||
{ header: "Todo Tasks(%)", key: "todo_tasks", width: 20 }
|
{ header: "Todo Tasks(%)", key: "todo_tasks", width: 20 }
|
||||||
@@ -430,14 +475,14 @@ export default class ReportingMembersController extends ReportingControllerBase
|
|||||||
|
|
||||||
// set title
|
// set title
|
||||||
sheet.getCell("A1").value = `Members from ${teamName}`;
|
sheet.getCell("A1").value = `Members from ${teamName}`;
|
||||||
sheet.mergeCells("A1:K1");
|
sheet.mergeCells("A1:M1");
|
||||||
sheet.getCell("A1").alignment = { horizontal: "center" };
|
sheet.getCell("A1").alignment = { horizontal: "center" };
|
||||||
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
|
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
|
||||||
sheet.getCell("A1").font = { size: 16 };
|
sheet.getCell("A1").font = { size: 16 };
|
||||||
|
|
||||||
// set export date
|
// set export date
|
||||||
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
|
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
|
||||||
sheet.mergeCells("A2:K2");
|
sheet.mergeCells("A2:M2");
|
||||||
sheet.getCell("A2").alignment = { horizontal: "center" };
|
sheet.getCell("A2").alignment = { horizontal: "center" };
|
||||||
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
|
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
|
||||||
sheet.getCell("A2").font = { size: 12 };
|
sheet.getCell("A2").font = { size: 12 };
|
||||||
@@ -447,7 +492,7 @@ export default class ReportingMembersController extends ReportingControllerBase
|
|||||||
sheet.mergeCells("A3:D3");
|
sheet.mergeCells("A3:D3");
|
||||||
|
|
||||||
// set table headers
|
// 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 };
|
sheet.getRow(5).font = { bold: true };
|
||||||
|
|
||||||
for (const member of result.members) {
|
for (const member of result.members) {
|
||||||
@@ -458,6 +503,8 @@ export default class ReportingMembersController extends ReportingControllerBase
|
|||||||
overdue_tasks: member.overdue,
|
overdue_tasks: member.overdue,
|
||||||
completed_tasks: member.completed,
|
completed_tasks: member.completed,
|
||||||
ongoing_tasks: member.ongoing,
|
ongoing_tasks: member.ongoing,
|
||||||
|
billable_time: member.billable_time || 0,
|
||||||
|
non_billable_time: member.non_billable_time || 0,
|
||||||
done_tasks: member.completed,
|
done_tasks: member.completed,
|
||||||
doing_tasks: member.ongoing_by_activity_logs,
|
doing_tasks: member.ongoing_by_activity_logs,
|
||||||
todo_tasks: member.todo_by_activity_logs
|
todo_tasks: member.todo_by_activity_logs
|
||||||
|
|||||||
@@ -31,5 +31,10 @@
|
|||||||
|
|
||||||
"todoText": "To Do",
|
"todoText": "To Do",
|
||||||
"doingText": "Doing",
|
"doingText": "Doing",
|
||||||
"doneText": "Done"
|
"doneText": "Done",
|
||||||
|
|
||||||
|
"timeLogsColumn": "Time Logs",
|
||||||
|
"timeLogsColumnTooltip": "Shows the proportion of billable vs non-billable time",
|
||||||
|
"billable": "Billable",
|
||||||
|
"nonBillable": "Non-Billable"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,5 +31,10 @@
|
|||||||
|
|
||||||
"todoText": "Por Hacer",
|
"todoText": "Por Hacer",
|
||||||
"doingText": "Haciendo",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,5 +31,10 @@
|
|||||||
|
|
||||||
"todoText": "To Do",
|
"todoText": "To Do",
|
||||||
"doingText": "Doing",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|||||||
import CustomTableTitle from '@/components/CustomTableTitle';
|
import CustomTableTitle from '@/components/CustomTableTitle';
|
||||||
import TasksProgressCell from './tablesCells/tasksProgressCell/TasksProgressCell';
|
import TasksProgressCell from './tablesCells/tasksProgressCell/TasksProgressCell';
|
||||||
import MemberCell from './tablesCells/memberCell/MemberCell';
|
import MemberCell from './tablesCells/memberCell/MemberCell';
|
||||||
|
import TimeLogsCell from './tablesCells/timeLogsCell/TimeLogsCell';
|
||||||
import {
|
import {
|
||||||
fetchMembersData,
|
fetchMembersData,
|
||||||
setPagination,
|
setPagination,
|
||||||
@@ -54,6 +55,15 @@ const MembersReportsTable = () => {
|
|||||||
return todo || doing || done ? <TasksProgressCell tasksStat={record.tasks_stat} /> : '-';
|
return todo || doing || done ? <TasksProgressCell tasksStat={record.tasks_stat} /> : '-';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'timeLogs',
|
||||||
|
title: <CustomTableTitle title={t('timeLogsColumn')} tooltip={t('timeLogsColumnTooltip')} />,
|
||||||
|
render: record => {
|
||||||
|
const billableTime = record.billable_time || 0;
|
||||||
|
const nonBillableTime = record.non_billable_time || 0;
|
||||||
|
return <TimeLogsCell billableTime={billableTime} nonBillableTime={nonBillableTime} />;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'tasksAssigned',
|
key: 'tasksAssigned',
|
||||||
title: (
|
title: (
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div className="w-3 h-3 bg-green-500 rounded-sm"></div>
|
||||||
|
<span>{t('billable')}: {formatTime(billableTime)} ({billablePercentage}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 bg-gray-400 rounded-sm"></div>
|
||||||
|
<span>{t('nonBillable')}: {formatTime(nonBillableTime)} ({nonBillablePercentage}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 pt-2 border-t border-gray-200">
|
||||||
|
<span className="font-medium">Total: {formatTime(totalTime)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={tooltipContent} placement="top">
|
||||||
|
<div className="flex items-center cursor-pointer">
|
||||||
|
<div className="relative w-24 h-4 rounded-full overflow-hidden flex border border-gray-300">
|
||||||
|
{/* Billable time section (green) */}
|
||||||
|
{billableTime > 0 && (
|
||||||
|
<div
|
||||||
|
className="bg-green-500 transition-all duration-300 h-full"
|
||||||
|
style={{ width: `${billableWidth}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Non-billable time section (gray) */}
|
||||||
|
{nonBillableTime > 0 && (
|
||||||
|
<div
|
||||||
|
className="bg-gray-400 transition-all duration-300 h-full"
|
||||||
|
style={{ width: `${nonBillableWidth}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Percentage text overlay */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-xs font-medium text-white drop-shadow-sm z-10">
|
||||||
|
{billablePercentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimeLogsCell;
|
||||||
@@ -136,6 +136,8 @@ export interface IRPTMember {
|
|||||||
ongoing: number;
|
ongoing: number;
|
||||||
todo: number;
|
todo: number;
|
||||||
member_teams: any;
|
member_teams: any;
|
||||||
|
billable_time?: number;
|
||||||
|
non_billable_time?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISingleMemberLogs {
|
export interface ISingleMemberLogs {
|
||||||
|
|||||||
Reference in New Issue
Block a user