diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 06f61982..2def3d08 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,9 @@ "Bash(find:*)", "Bash(npm run build:*)", "Bash(npm run type-check:*)", - "Bash(npm run:*)" + "Bash(npm run:*)", + "Bash(mkdir:*)", + "Bash(cp:*)" ], "deny": [] } diff --git a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts index 97500437..07fa6aae 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts @@ -10,9 +10,9 @@ import ReportingControllerBase from "./reporting-controller-base"; import Excel from "exceljs"; export default class ReportingMembersController extends ReportingControllerBase { - private static async getMembers( - teamId: string, searchQuery = "", + teamId: string, + searchQuery = "", size: number | null = null, offset: number | null = null, teamsClause = "", @@ -21,16 +21,30 @@ export default class ReportingMembersController extends ReportingControllerBase includeArchived: boolean, userId: string ) { - const pagingClause = (size !== null && offset !== null) ? `LIMIT ${size} OFFSET ${offset}` : ""; + const pagingClause = + size !== null && offset !== null ? `LIMIT ${size} OFFSET ${offset}` : ""; const archivedClause = 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}')`; + ? "" + : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`; // const durationFilterClause = this.memberTasksDurationFilter(key, dateRange); const assignClause = this.memberAssignDurationFilter(key, dateRange); - const completedDurationClasue = this.completedDurationFilter(key, dateRange); - const overdueActivityLogsClause = this.getActivityLogsOverdue(key, dateRange); - const activityLogCreationFilter = this.getActivityLogsCreationClause(key, dateRange); + 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,12 +114,35 @@ 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 - FROM project_members - WHERE project_id IN (SELECT id FROM projects WHERE projects.team_id = tmiv.team_id)) ${searchQuery} GROUP BY email, name, avatar_url, team_member_id, tmiv.team_id ORDER BY last_user_activity DESC NULLS LAST @@ -113,9 +150,6 @@ export default class ReportingMembersController extends ReportingControllerBase ${pagingClause}) t) AS members FROM team_member_info_view tmiv WHERE tmiv.team_id = $1 ${teamsClause} - AND tmiv.team_member_id IN (SELECT team_member_id - FROM project_members - WHERE project_id IN (SELECT id FROM projects WHERE projects.team_id = tmiv.team_id)) ${searchQuery}`; const result = await db.query(q, [teamId]); const [data] = result.rows; @@ -123,9 +157,30 @@ export default class ReportingMembersController extends ReportingControllerBase for (const member of data.members) { member.color_code = getColor(member.name) + TASK_PRIORITY_COLOR_ALPHA; member.tasks_stat = { - todo: this.getPercentage(int(member.todo_by_activity_logs), + (member.completed + member.todo_by_activity_logs + member.ongoing_by_activity_logs)), - doing: this.getPercentage(int(member.ongoing_by_activity_logs), + (member.completed + member.todo_by_activity_logs + member.ongoing_by_activity_logs)), - done: this.getPercentage(int(member.completed), + (member.completed + member.todo_by_activity_logs + member.ongoing_by_activity_logs)) + todo: this.getPercentage( + int(member.todo_by_activity_logs), + +( + member.completed + + member.todo_by_activity_logs + + member.ongoing_by_activity_logs + ) + ), + doing: this.getPercentage( + int(member.ongoing_by_activity_logs), + +( + member.completed + + member.todo_by_activity_logs + + member.ongoing_by_activity_logs + ) + ), + done: this.getPercentage( + int(member.completed), + +( + member.completed + + member.todo_by_activity_logs + + member.ongoing_by_activity_logs + ) + ), }; member.member_teams = this.createTagList(member.member_teams, 2); } @@ -133,7 +188,10 @@ export default class ReportingMembersController extends ReportingControllerBase } private static flatString(text: string) { - return (text || "").split(" ").map(s => `'${s}'`).join(","); + return (text || "") + .split(" ") + .map((s) => `'${s}'`) + .join(","); } protected static memberTasksDurationFilter(key: string, dateRange: string[]) { @@ -160,7 +218,10 @@ export default class ReportingMembersController extends ReportingControllerBase return ""; } - protected static memberAssignDurationFilter(key: string, dateRange: string[]) { + protected static memberAssignDurationFilter( + 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"); @@ -209,7 +270,6 @@ export default class ReportingMembersController extends ReportingControllerBase } protected static getOverdueClause(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"); @@ -230,7 +290,6 @@ export default class ReportingMembersController extends ReportingControllerBase if (key === DATE_RANGES.LAST_QUARTER) return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND t.end_date::DATE < NOW()::DATE`; - return ` AND t.end_date::DATE < NOW()::DATE `; } @@ -270,7 +329,6 @@ export default class ReportingMembersController extends ReportingControllerBase } protected static getActivityLogsOverdue(key: string, dateRange: string[]) { - if (dateRange.length === 2) { const end = moment(dateRange[1]).format("YYYY-MM-DD"); return `AND is_overdue_for_date(t.id, '${end}'::DATE)`; @@ -279,7 +337,10 @@ export default class ReportingMembersController extends ReportingControllerBase return `AND is_overdue_for_date(t.id, NOW()::DATE)`; } - protected static getActivityLogsCreationClause(key: string, dateRange: string[]) { + protected static getActivityLogsCreationClause( + key: string, + dateRange: string[] + ) { if (dateRange.length === 2) { const end = moment(dateRange[1]).format("YYYY-MM-DD"); return `AND tl.created_at::DATE <= '${end}'::DATE`; @@ -287,7 +348,11 @@ export default class ReportingMembersController extends ReportingControllerBase return `AND tl.created_at::DATE <= NOW()::DATE`; } - protected static getDateRangeClauseMembers(key: string, dateRange: string[], tableAlias: string) { + protected static getDateRangeClauseMembers( + key: string, + dateRange: string[], + tableAlias: string + ) { if (dateRange.length === 2) { const start = moment(dateRange[0]).format("YYYY-MM-DD"); const end = moment(dateRange[1]).format("YYYY-MM-DD"); @@ -311,13 +376,37 @@ 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 = ""; if (duration.asMilliseconds() === 0) return empty; - const h = ~~(duration.asHours()); + const h = ~~duration.asHours(); const m = duration.minutes(); const s = duration.seconds(); @@ -335,8 +424,13 @@ export default class ReportingMembersController extends ReportingControllerBase } @HandleExceptions() - public static async getReportingMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const { searchQuery, size, offset } = this.toPaginationOptions(req.query, ["name"]); + public static async getReportingMembers( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const { searchQuery, size, offset } = this.toPaginationOptions(req.query, [ + "name", + ]); const { duration, date_range } = req.query; const archived = req.query.archived === "true"; @@ -345,20 +439,29 @@ export default class ReportingMembersController extends ReportingControllerBase dateRange = date_range.split(","); } - const teamsClause = - req.query.teams as string - ? `AND tmiv.team_id IN (${this.flatString(req.query.teams as string)})` - : ""; + const teamsClause = (req.query.teams as string) + ? `AND tmiv.team_id IN (${this.flatString(req.query.teams as string)})` + : ""; const teamId = this.getCurrentTeamId(req); - const result = await this.getMembers(teamId as string, searchQuery, size, offset, teamsClause, duration as string, dateRange, archived, req.user?.id as string); + const result = await this.getMembers( + teamId as string, + searchQuery, + size, + offset, + teamsClause, + duration as string, + dateRange, + archived, + req.user?.id as string + ); const body = { total: result.total, members: result.members, team: { id: req.user?.team_id, - name: req.user?.team_name - } + name: req.user?.team_name, + }, }; return res.status(200).send(new ServerResponse(true, body)); } @@ -383,14 +486,28 @@ export default class ReportingMembersController extends ReportingControllerBase const teamId = this.getCurrentTeamId(req); const teamName = (req.query.team_name as string)?.trim() || null; - const result = await this.getMembers(teamId as string, "", null, null, "", duration as string, dateRange, archived, req.user?.id as string); + const result = await this.getMembers( + teamId as string, + "", + null, + null, + "", + duration as string, + dateRange, + archived, + req.user?.id as string + ); 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() : "-"; + 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: @@ -403,7 +520,10 @@ export default class ReportingMembersController extends ReportingControllerBase 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(); + start = moment() + .subtract(3, "months") + .format("YYYY-MM-DD") + .toString(); break; } end = moment().format("YYYY-MM-DD").toString(); @@ -423,23 +543,37 @@ 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 } + { header: "Todo Tasks(%)", key: "todo_tasks", width: 20 }, ]; // 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").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").style.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "F2F2F2" }, + }; sheet.getCell("A2").font = { size: 12 }; // set duration @@ -447,7 +581,19 @@ 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,26 +604,31 @@ 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 + todo_tasks: member.todo_by_activity_logs, }); } // download excel res.setHeader("Content-Type", "application/vnd.openxmlformats"); - res.setHeader("Content-Disposition", `attachment; filename=${fileName}.xlsx`); - - await workbook.xlsx.write(res) - .then(() => { - res.end(); - }); + res.setHeader( + "Content-Disposition", + `attachment; filename=${fileName}.xlsx` + ); + await workbook.xlsx.write(res).then(() => { + res.end(); + }); } @HandleExceptions() - public static async exportTimeLogs(req: IWorkLenzRequest, res: IWorkLenzResponse) { - + public static async exportTimeLogs( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ) { const { duration, date_range, team_id, team_member_id } = req.query; const includeArchived = req.query.archived === "true"; @@ -487,18 +638,37 @@ export default class ReportingMembersController extends ReportingControllerBase dateRange = date_range.split(","); } - const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "twl"); - const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log"); + const durationClause = ReportingMembersController.getDateRangeClauseMembers( + (duration as string) || DATE_RANGES.LAST_WEEK, + dateRange, + "twl" + ); + const minMaxDateClause = this.getMinMaxDates( + (duration as string) || DATE_RANGES.LAST_WEEK, + dateRange, + "task_work_log" + ); const memberName = (req.query.member_name as string)?.trim() || null; - const logGroups = await this.memberTimeLogsData(durationClause, minMaxDateClause, team_id as string, team_member_id as string, includeArchived, req.user?.id as string); + const logGroups = await this.memberTimeLogsData( + durationClause, + minMaxDateClause, + team_id as string, + team_member_id as string, + includeArchived, + req.user?.id as string + ); 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() : "-"; + 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: @@ -511,13 +681,15 @@ export default class ReportingMembersController extends ReportingControllerBase 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(); + start = moment() + .subtract(3, "months") + .format("YYYY-MM-DD") + .toString(); break; } end = moment().format("YYYY-MM-DD").toString(); } - const exportDate = moment().format("MMM-DD-YYYY"); const fileName = `${memberName} timelogs - ${exportDate}`; const workbook = new Excel.Workbook(); @@ -532,14 +704,22 @@ export default class ReportingMembersController extends ReportingControllerBase sheet.getCell("A1").value = `Timelogs of ${memberName}`; sheet.mergeCells("A1:K1"); 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 }; // set export date sheet.getCell("A2").value = `Exported on : ${exportDate}`; sheet.mergeCells("A2:K2"); 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 }; // set duration @@ -554,25 +734,27 @@ export default class ReportingMembersController extends ReportingControllerBase for (const log of row.logs) { sheet.addRow({ date: row.log_day, - log: `Logged ${log.time_spent_string} for ${log.task_name} in ${log.project_name}` + log: `Logged ${log.time_spent_string} for ${log.task_name} in ${log.project_name}`, }); } } res.setHeader("Content-Type", "application/vnd.openxmlformats"); - res.setHeader("Content-Disposition", `attachment; filename=${fileName}.xlsx`); - - await workbook.xlsx.write(res) - .then(() => { - res.end(); - }); - + res.setHeader( + "Content-Disposition", + `attachment; filename=${fileName}.xlsx` + ); + await workbook.xlsx.write(res).then(() => { + res.end(); + }); } @HandleExceptions() - public static async exportActivityLogs(req: IWorkLenzRequest, res: IWorkLenzResponse) { - + public static async exportActivityLogs( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ) { const { duration, date_range, team_id, team_member_id } = req.query; const includeArchived = req.query.archived === "true"; @@ -581,18 +763,37 @@ export default class ReportingMembersController extends ReportingControllerBase dateRange = date_range.split(","); } - const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "tal"); - const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_activity_logs"); + const durationClause = ReportingMembersController.getDateRangeClauseMembers( + (duration as string) || DATE_RANGES.LAST_WEEK, + dateRange, + "tal" + ); + const minMaxDateClause = this.getMinMaxDates( + (duration as string) || DATE_RANGES.LAST_WEEK, + dateRange, + "task_activity_logs" + ); const memberName = (req.query.member_name as string)?.trim() || null; - const logGroups = await this.memberActivityLogsData(durationClause, minMaxDateClause, team_id as string, team_member_id as string, includeArchived, req.user?.id as string); + const logGroups = await this.memberActivityLogsData( + durationClause, + minMaxDateClause, + team_id as string, + team_member_id as string, + includeArchived, + req.user?.id as string + ); 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() : "-"; + 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: @@ -605,13 +806,15 @@ export default class ReportingMembersController extends ReportingControllerBase 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(); + start = moment() + .subtract(3, "months") + .format("YYYY-MM-DD") + .toString(); break; } end = moment().format("YYYY-MM-DD").toString(); } - const exportDate = moment().format("MMM-DD-YYYY"); const fileName = `${memberName} activitylogs - ${exportDate}`; const workbook = new Excel.Workbook(); @@ -626,14 +829,22 @@ export default class ReportingMembersController extends ReportingControllerBase sheet.getCell("A1").value = `Activities of ${memberName}`; sheet.mergeCells("A1:K1"); 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 }; // set export date sheet.getCell("A2").value = `Exported on : ${exportDate}`; sheet.mergeCells("A2:K2"); 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 }; // set duration @@ -646,8 +857,8 @@ export default class ReportingMembersController extends ReportingControllerBase for (const row of logGroups) { for (const log of row.logs) { - !log.previous ? log.previous = "NULL" : log.previous; - !log.current ? log.current = "NULL" : log.current; + !log.previous ? (log.previous = "NULL") : log.previous; + !log.current ? (log.current = "NULL") : log.current; switch (log.attribute_type) { case "start_date": log.attribute_type = "Start Date"; @@ -669,24 +880,29 @@ export default class ReportingMembersController extends ReportingControllerBase } sheet.addRow({ date: row.log_day, - log: `Updated ${log.attribute_type} from ${log.previous} to ${log.current} in ${log.task_name} within ${log.project_name}.` + log: `Updated ${log.attribute_type} from ${log.previous} to ${log.current} in ${log.task_name} within ${log.project_name}.`, }); } } res.setHeader("Content-Type", "application/vnd.openxmlformats"); - res.setHeader("Content-Disposition", `attachment; filename=${fileName}.xlsx`); - - await workbook.xlsx.write(res) - .then(() => { - res.end(); - }); + res.setHeader( + "Content-Disposition", + `attachment; filename=${fileName}.xlsx` + ); + await workbook.xlsx.write(res).then(() => { + res.end(); + }); } - - public static async getMemberProjectsData(teamId: string, teamMemberId: string, searchQuery: string, archived: boolean, userId: string) { - + public static async getMemberProjectsData( + teamId: string, + teamMemberId: string, + searchQuery: string, + archived: boolean, + userId: string + ) { const teamClause = teamId ? `team_member_id = '${teamMemberId as string}'` : `team_member_id IN (SELECT team_member_id @@ -695,7 +911,9 @@ export default class ReportingMembersController extends ReportingControllerBase FROM team_member_info_view tmiv2 WHERE tmiv2.team_member_id = '${teamMemberId}' AND in_organization(p.team_id, tmiv2.team_id)))`; - const archivedClause = archived ? `` : ` AND pm.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = pm.project_id AND user_id = '${userId}')`; + const archivedClause = archived + ? `` + : ` AND pm.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = pm.project_id AND user_id = '${userId}')`; const q = `SELECT p.id, p.name, pm.team_member_id, (SELECT name FROM teams WHERE id = p.team_id) AS team, @@ -739,26 +957,43 @@ export default class ReportingMembersController extends ReportingControllerBase const result = await db.query(q, []); for (const project of result.rows) { - project.time_logged = formatDuration(moment.duration(project.time_logged, "seconds")); - project.contribution = project.project_task_count > 0 ? ((project.task_count / project.project_task_count) * 100).toFixed(0) : 0; + project.time_logged = formatDuration( + moment.duration(project.time_logged, "seconds") + ); + project.contribution = + project.project_task_count > 0 + ? ((project.task_count / project.project_task_count) * 100).toFixed(0) + : 0; } return result.rows; } @HandleExceptions() - public static async getMemberProjects(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async getMemberProjects( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { const { searchQuery } = this.toPaginationOptions(req.query, ["p.name"]); const { teamMemberId, teamId } = req.query; const archived = req.query.archived === "true"; - const result = await this.getMemberProjectsData(teamId as string, teamMemberId as string, searchQuery, archived, req.user?.id as string); + const result = await this.getMemberProjectsData( + teamId as string, + teamMemberId as string, + searchQuery, + archived, + req.user?.id as string + ); return res.status(200).send(new ServerResponse(true, result)); } - - protected static getMinMaxDates(key: string, dateRange: string[], tableName: string) { + protected static getMinMaxDates( + key: string, + dateRange: string[], + tableName: string + ) { if (dateRange.length === 2) { const start = moment(dateRange[0]).format("YYYY-MM-DD"); const end = moment(dateRange[1]).format("YYYY-MM-DD"); @@ -779,22 +1014,42 @@ export default class ReportingMembersController extends ReportingControllerBase return ""; } - - @HandleExceptions() - public static async getMemberActivities(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const { team_member_id, team_id, duration, date_range, archived } = req.body; + public static async getMemberActivities( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const { team_member_id, team_id, duration, date_range, archived } = + req.body; - const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration || DATE_RANGES.LAST_WEEK, date_range, "tal"); - const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_activity_logs"); + const durationClause = ReportingMembersController.getDateRangeClauseMembers( + duration || DATE_RANGES.LAST_WEEK, + date_range, + "tal" + ); + const minMaxDateClause = this.getMinMaxDates( + duration || DATE_RANGES.LAST_WEEK, + date_range, + "task_activity_logs" + ); - const logGroups = await this.memberActivityLogsData(durationClause, minMaxDateClause, team_id, team_member_id, archived, req.user?.id as string); + const logGroups = await this.memberActivityLogsData( + durationClause, + minMaxDateClause, + team_id, + team_member_id, + archived, + req.user?.id as string + ); return res.status(200).send(new ServerResponse(true, logGroups)); } - private static async formatLog(result: { start_date: string, end_date: string, time_logs: any[] }) { - + private static async formatLog(result: { + start_date: string; + end_date: string; + time_logs: any[]; + }) { result.time_logs.forEach((row) => { const duration = moment.duration(row.time_spent, "seconds"); row.time_spent_string = this.formatDuration(duration); @@ -803,10 +1058,18 @@ export default class ReportingMembersController extends ReportingControllerBase return result; } - private static async getTimeLogDays(result: { start_date: string, end_date: string, time_logs: any[] }) { + private static async getTimeLogDays(result: { + start_date: string; + end_date: string; + time_logs: any[]; + }) { if (result) { - const startDate = moment(result.start_date).isValid() ? moment(result.start_date, "YYYY-MM-DD").clone() : null; - const endDate = moment(result.end_date).isValid() ? moment(result.end_date, "YYYY-MM-DD").clone() : null; + const startDate = moment(result.start_date).isValid() + ? moment(result.start_date, "YYYY-MM-DD").clone() + : null; + const endDate = moment(result.end_date).isValid() + ? moment(result.end_date, "YYYY-MM-DD").clone() + : null; const days = []; const logDayGroups = []; @@ -817,25 +1080,36 @@ export default class ReportingMembersController extends ReportingControllerBase } for (const day of days) { - const logsForDay = result.time_logs.filter((log) => moment(moment(log.created_at).format("YYYY-MM-DD")).isSame(moment(day).format("YYYY-MM-DD"))); + const logsForDay = result.time_logs.filter((log) => + moment(moment(log.created_at).format("YYYY-MM-DD")).isSame( + moment(day).format("YYYY-MM-DD") + ) + ); if (logsForDay.length) { logDayGroups.push({ log_day: day, - logs: logsForDay + logs: logsForDay, }); } } return logDayGroups; - } return []; } - private static async getActivityLogDays(result: { start_date: string, end_date: string, activity_logs: any[] }) { + private static async getActivityLogDays(result: { + start_date: string; + end_date: string; + activity_logs: any[]; + }) { if (result) { - const startDate = moment(result.start_date).isValid() ? moment(result.start_date, "YYYY-MM-DD").clone() : null; - const endDate = moment(result.end_date).isValid() ? moment(result.end_date, "YYYY-MM-DD").clone() : null; + const startDate = moment(result.start_date).isValid() + ? moment(result.start_date, "YYYY-MM-DD").clone() + : null; + const endDate = moment(result.end_date).isValid() + ? moment(result.end_date, "YYYY-MM-DD").clone() + : null; const days = []; const logDayGroups = []; @@ -846,27 +1120,36 @@ export default class ReportingMembersController extends ReportingControllerBase } for (const day of days) { - const logsForDay = result.activity_logs.filter((log) => moment(moment(log.created_at).format("YYYY-MM-DD")).isSame(moment(day).format("YYYY-MM-DD"))); + const logsForDay = result.activity_logs.filter((log) => + moment(moment(log.created_at).format("YYYY-MM-DD")).isSame( + moment(day).format("YYYY-MM-DD") + ) + ); if (logsForDay.length) { logDayGroups.push({ log_day: day, - logs: logsForDay + logs: logsForDay, }); } } return logDayGroups; - } return []; } - - private static async memberTimeLogsData(durationClause: string, minMaxDateClause: string, team_id: string, team_member_id: string, includeArchived: boolean, userId: string, billableQuery = "") { - + private static async memberTimeLogsData( + durationClause: string, + minMaxDateClause: string, + team_id: string, + team_member_id: string, + includeArchived: boolean, + userId: string, + billableQuery = "" + ) { const archivedClause = includeArchived - ? "" - : `AND project_id NOT IN (SELECT project_id FROM archived_projects WHERE archived_projects.user_id = '${userId}')`; + ? "" + : `AND project_id NOT IN (SELECT project_id FROM archived_projects WHERE archived_projects.user_id = '${userId}')`; const q = ` SELECT user_id, @@ -907,9 +1190,17 @@ export default class ReportingMembersController extends ReportingControllerBase return logGroups; } - private static async memberActivityLogsData(durationClause: string, minMaxDateClause: string, team_id: string, team_member_id: string, includeArchived:boolean, userId: string) { - - const archivedClause = includeArchived ? `` : `AND (SELECT project_id FROM tasks WHERE id = tal.task_id) NOT IN (SELECT project_id FROM archived_projects WHERE archived_projects.user_id = '${userId}')`; + private static async memberActivityLogsData( + durationClause: string, + minMaxDateClause: string, + team_id: string, + team_member_id: string, + includeArchived: boolean, + userId: string + ) { + const archivedClause = includeArchived + ? `` + : `AND (SELECT project_id FROM tasks WHERE id = tal.task_id) NOT IN (SELECT project_id FROM archived_projects WHERE archived_projects.user_id = '${userId}')`; const q = ` SELECT user_id, @@ -1014,12 +1305,14 @@ export default class ReportingMembersController extends ReportingControllerBase } return logGroups; - } - protected static buildBillableQuery(selectedStatuses: { billable: boolean; nonBillable: boolean }): string { + protected static buildBillableQuery(selectedStatuses: { + billable: boolean; + nonBillable: boolean; + }): string { const { billable, nonBillable } = selectedStatuses; - + if (billable && nonBillable) { // Both are enabled, no need to filter return ""; @@ -1029,28 +1322,56 @@ export default class ReportingMembersController extends ReportingControllerBase } else if (nonBillable) { // Only non-billable is enabled return " AND tasks.billable IS FALSE"; - } + } return ""; } @HandleExceptions() - public static async getMemberTimelogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const { team_member_id, team_id, duration, date_range, archived, billable } = req.body; + public static async getMemberTimelogs( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const { + team_member_id, + team_id, + duration, + date_range, + archived, + billable, + } = req.body; - const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration || DATE_RANGES.LAST_WEEK, date_range, "twl"); - const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log"); + const durationClause = ReportingMembersController.getDateRangeClauseMembers( + duration || DATE_RANGES.LAST_WEEK, + date_range, + "twl" + ); + const minMaxDateClause = this.getMinMaxDates( + duration || DATE_RANGES.LAST_WEEK, + date_range, + "task_work_log" + ); const billableQuery = this.buildBillableQuery(billable); - const logGroups = await this.memberTimeLogsData(durationClause, minMaxDateClause, team_id, team_member_id, archived, req.user?.id as string, billableQuery); + const logGroups = await this.memberTimeLogsData( + durationClause, + minMaxDateClause, + team_id, + team_member_id, + archived, + req.user?.id as string, + billableQuery + ); return res.status(200).send(new ServerResponse(true, logGroups)); } @HandleExceptions() - public static async getMemberTaskStats(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - + public static async getMemberTaskStats( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { const { duration, date_range, team_member_id } = req.query; const includeArchived = req.query.archived === "true"; @@ -1060,15 +1381,26 @@ export default class ReportingMembersController extends ReportingControllerBase } const archivedClause = includeArchived - ? "" - : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${req.user?.id}')`; + ? "" + : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${req.user?.id}')`; - - const assignClause = this.memberAssignDurationFilter(duration as string, dateRange); - const completedDurationClasue = this.completedDurationFilter(duration as string, dateRange); - const overdueClauseByDate = this.getActivityLogsOverdue(duration as string, dateRange); + const assignClause = this.memberAssignDurationFilter( + duration as string, + dateRange + ); + const completedDurationClasue = this.completedDurationFilter( + duration as string, + dateRange + ); + const overdueClauseByDate = this.getActivityLogsOverdue( + duration as string, + dateRange + ); const taskSelectorClause = this.getTaskSelectorClause(); - const durationFilter = this.memberTasksDurationFilter(duration as string, dateRange); + const durationFilter = this.memberTasksDurationFilter( + duration as string, + dateRange + ); const q = ` SELECT name AS team_member_name, @@ -1113,7 +1445,12 @@ export default class ReportingMembersController extends ReportingControllerBase const [data] = result.rows; if (data) { - for (const taskArray of [data.assigned, data.completed, data.ongoing, data.overdue]) { + for (const taskArray of [ + data.assigned, + data.completed, + data.ongoing, + data.overdue, + ]) { this.updateTaskProperties(taskArray); } } @@ -1124,29 +1461,29 @@ export default class ReportingMembersController extends ReportingControllerBase { name: "Total Tasks", color_code: "#7590c9", - tasks: data.total ? data.total : 0 + tasks: data.total ? data.total : 0, }, { name: "Tasks Assigned", color_code: "#7590c9", - tasks: data.assigned ? data.assigned : 0 + tasks: data.assigned ? data.assigned : 0, }, { name: "Tasks Completed", color_code: "#75c997", - tasks: data.completed ? data.completed : 0 + tasks: data.completed ? data.completed : 0, }, { name: "Tasks Overdue", color_code: "#eb6363", - tasks: data.overdue ? data.overdue : 0 + tasks: data.overdue ? data.overdue : 0, }, { name: "Tasks Ongoing", color_code: "#7cb5ec", - tasks: data.ongoing ? data.ongoing : 0 + tasks: data.ongoing ? data.ongoing : 0, }, - ] + ], }; return res.status(200).send(new ServerResponse(true, body)); @@ -1154,24 +1491,33 @@ export default class ReportingMembersController extends ReportingControllerBase private static updateTaskProperties(tasks: any[]) { for (const task of tasks) { - task.project_color = getColor(task.project_name); - task.estimated_string = formatDuration(moment.duration(~~(task.total_minutes), "seconds")); - task.time_spent_string = formatDuration(moment.duration(~~(task.time_logged), "seconds")); - task.overlogged_time_string = formatDuration(moment.duration(~~(task.overlogged_time), "seconds")); - task.overdue_days = task.days_overdue ? task.days_overdue : null; + task.project_color = getColor(task.project_name); + task.estimated_string = formatDuration( + moment.duration(~~task.total_minutes, "seconds") + ); + task.time_spent_string = formatDuration( + moment.duration(~~task.time_logged, "seconds") + ); + task.overlogged_time_string = formatDuration( + moment.duration(~~task.overlogged_time, "seconds") + ); + task.overdue_days = task.days_overdue ? task.days_overdue : null; } -} + } -@HandleExceptions() -public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLenzResponse) { - const { team_member_id } = req.query; - const includeArchived = req.query.archived === "true"; + @HandleExceptions() + public static async getSingleMemberProjects( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ) { + const { team_member_id } = req.query; + const includeArchived = req.query.archived === "true"; - const archivedClause = includeArchived - ? "" - : `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND archived_projects.user_id = '${req.user?.id}')`; + const archivedClause = includeArchived + ? "" + : `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND archived_projects.user_id = '${req.user?.id}')`; - const q = `SELECT id, + const q = `SELECT id, name, color_code, start_date, @@ -1222,46 +1568,60 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen FROM projects WHERE projects.id IN (SELECT project_id FROM project_members WHERE team_member_id = $1) ${archivedClause};`; - const result = await db.query(q, [team_member_id]); - const data = result.rows; + const result = await db.query(q, [team_member_id]); + const data = result.rows; - for (const row of data) { - row.estimated_time = int(row.estimated_time); - row.actual_time = int(row.actual_time); - row.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(row.estimated_time)); - row.actual_time_string = this.convertSecondsToHoursAndMinutes(int(row.actual_time)); - row.days_left = ReportingControllerBase.getDaysLeft(row.end_date); - row.is_overdue = ReportingControllerBase.isOverdue(row.end_date); - if (row.days_left && row.is_overdue) { - row.days_left = row.days_left.toString().replace(/-/g, ""); + for (const row of data) { + row.estimated_time = int(row.estimated_time); + row.actual_time = int(row.actual_time); + row.estimated_time_string = this.convertMinutesToHoursAndMinutes( + int(row.estimated_time) + ); + row.actual_time_string = this.convertSecondsToHoursAndMinutes( + int(row.actual_time) + ); + row.days_left = ReportingControllerBase.getDaysLeft(row.end_date); + row.is_overdue = ReportingControllerBase.isOverdue(row.end_date); + if (row.days_left && row.is_overdue) { + row.days_left = row.days_left.toString().replace(/-/g, ""); + } + row.is_today = this.isToday(row.end_date); + if (row.project_manager) { + row.project_manager.name = + row.project_manager.project_manager_info.name; + row.project_manager.avatar_url = + row.project_manager.project_manager_info.avatar_url; + row.project_manager.color_code = getColor(row.project_manager.name); + } + row.project_health = row.health_name ? row.health_name : null; } - row.is_today = this.isToday(row.end_date); - if (row.project_manager) { - row.project_manager.name = row.project_manager.project_manager_info.name; - row.project_manager.avatar_url = row.project_manager.project_manager_info.avatar_url; - row.project_manager.color_code = getColor(row.project_manager.name); - } - row.project_health = row.health_name ? row.health_name : null; + + const body = { + team_member_name: data[0].team_member_name, + projects: data, + }; + + return res.status(200).send(new ServerResponse(true, body)); } - const body = { - team_member_name: data[0].team_member_name, - projects: data - }; - - return res.status(200).send(new ServerResponse(true, body)); - -} - @HandleExceptions() - public static async exportMemberProjects(req: IWorkLenzRequest, res: IWorkLenzResponse) { + public static async exportMemberProjects( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ) { const teamMemberId = (req.query.team_member_id as string)?.trim() || null; const teamId = (req.query.team_id as string)?.trim() || null; const memberName = (req.query.team_member_name as string)?.trim() || null; const teamName = (req.query.team_name as string)?.trim() || ""; const archived = req.query.archived === "true"; - const result = await this.getMemberProjectsData(teamId as string, teamMemberId as string, "", archived, req.user?.id as string); + const result = await this.getMemberProjectsData( + teamId as string, + teamMemberId as string, + "", + archived, + req.user?.id as string + ); // excel file const exportDate = moment().format("MMM-DD-YYYY"); @@ -1278,21 +1638,29 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen { header: "Incompleted Tasks", key: "incompleted_tasks", width: 20 }, { header: "Completed Tasks", key: "completed_tasks", width: 20 }, { header: "Overdue Tasks", key: "overdue_tasks", width: 20 }, - { header: "Logged Time", key: "logged_time", width: 20 } + { header: "Logged Time", key: "logged_time", width: 20 }, ]; // set title sheet.getCell("A1").value = `Projects of ${memberName} - ${teamName}`; sheet.mergeCells("A1:H1"); 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 }; // set export date sheet.getCell("A2").value = `Exported on : ${exportDate}`; sheet.mergeCells("A2:H2"); 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 }; // set duration @@ -1302,7 +1670,16 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen // sheet.mergeCells("A3:D3"); // set table headers - sheet.getRow(4).values = ["Project", "Team", "Tasks", "Contribution(%)", "Incompleted Tasks", "Completed Tasks", "Overdue Tasks", "Logged Time"]; + sheet.getRow(4).values = [ + "Project", + "Team", + "Tasks", + "Contribution(%)", + "Incompleted Tasks", + "Completed Tasks", + "Overdue Tasks", + "Logged Time", + ]; sheet.getRow(4).font = { bold: true }; for (const project of result) { @@ -1310,23 +1687,27 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen project: project.name, team: project.team, tasks: project.task_count ? project.task_count.toString() : "-", - contribution: project.contribution ? project.contribution.toString() : "-", - incompleted_tasks: project.incompleted ? project.incompleted.toString() : "-", + contribution: project.contribution + ? project.contribution.toString() + : "-", + incompleted_tasks: project.incompleted + ? project.incompleted.toString() + : "-", completed_tasks: project.completed ? project.completed.toString() : "-", overdue_tasks: project.overdue ? project.overdue.toString() : "-", - logged_time: project.time_logged ? project.time_logged.toString() : "-" + logged_time: project.time_logged ? project.time_logged.toString() : "-", }); } // download excel res.setHeader("Content-Type", "application/vnd.openxmlformats"); - res.setHeader("Content-Disposition", `attachment; filename=${fileName}.xlsx`); - - await workbook.xlsx.write(res) - .then(() => { - res.end(); - }); + res.setHeader( + "Content-Disposition", + `attachment; filename=${fileName}.xlsx` + ); + await workbook.xlsx.write(res).then(() => { + res.end(); + }); } - } diff --git a/worklenz-backend/src/public/locales/zh/kanban-board.json b/worklenz-backend/src/public/locales/zh/kanban-board.json index 7b72c5d5..8cf20031 100644 --- a/worklenz-backend/src/public/locales/zh/kanban-board.json +++ b/worklenz-backend/src/public/locales/zh/kanban-board.json @@ -15,5 +15,15 @@ "assignToMe": "分配给我", "archive": "归档", "newTaskNamePlaceholder": "写一个任务名称", - "newSubtaskNamePlaceholder": "写一个子任务名称" + "newSubtaskNamePlaceholder": "写一个子任务名称", + "untitledSection": "无标题部分", + "unmapped": "未映射", + "clickToChangeDate": "点击更改日期", + "noDueDate": "无截止日期", + "save": "保存", + "clear": "清除", + "nextWeek": "下周", + "noSubtasks": "无子任务", + "showSubtasks": "显示子任务", + "hideSubtasks": "隐藏子任务" } \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/task-management.json b/worklenz-backend/src/public/locales/zh/task-management.json index 341ecc64..b2589ecf 100644 --- a/worklenz-backend/src/public/locales/zh/task-management.json +++ b/worklenz-backend/src/public/locales/zh/task-management.json @@ -18,6 +18,10 @@ "changeCategory": "更改类别", "clickToEditGroupName": "点击编辑组名称", "enterGroupName": "输入组名称", + "todo": "待办", + "inProgress": "进行中", + "done": "已完成", + "defaultTaskName": "无标题任务", "indicators": { "tooltips": { diff --git a/worklenz-backend/src/public/locales/zh/time-report.json b/worklenz-backend/src/public/locales/zh/time-report.json index c376954a..0fd2104a 100644 --- a/worklenz-backend/src/public/locales/zh/time-report.json +++ b/worklenz-backend/src/public/locales/zh/time-report.json @@ -29,5 +29,22 @@ "noCategory": "无类别", "noProjects": "未找到项目", "noTeams": "未找到团队", - "noData": "未找到数据" + "noData": "未找到数据", + "groupBy": "分组方式", + "groupByCategory": "类别", + "groupByTeam": "团队", + "groupByStatus": "状态", + "groupByNone": "无", + "clearSearch": "清除搜索", + "selectedProjects": "已选项目", + "projectsSelected": "个项目已选择", + "showSelected": "仅显示已选择", + "expandAll": "全部展开", + "collapseAll": "全部折叠", + "ungrouped": "未分组", + "clearAll": "清除全部", + "filterByBillableStatus": "按计费状态筛选", + "searchByMember": "按成员搜索", + "members": "成员", + "utilization": "利用率" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/alb/kanban-board.json b/worklenz-frontend/public/locales/alb/kanban-board.json index def705aa..beb045d5 100644 --- a/worklenz-frontend/public/locales/alb/kanban-board.json +++ b/worklenz-frontend/public/locales/alb/kanban-board.json @@ -26,5 +26,8 @@ "noDueDate": "Pa datë përfundimi", "save": "Ruaj", "clear": "Pastro", - "nextWeek": "Javën e ardhshme" + "nextWeek": "Javën e ardhshme", + "noSubtasks": "Pa nëndetyra", + "showSubtasks": "Shfaq nëndetyrat", + "hideSubtasks": "Fshih nëndetyrat" } diff --git a/worklenz-frontend/public/locales/alb/project-drawer.json b/worklenz-frontend/public/locales/alb/project-drawer.json index 952dba7e..bd0b12ff 100644 --- a/worklenz-frontend/public/locales/alb/project-drawer.json +++ b/worklenz-frontend/public/locales/alb/project-drawer.json @@ -38,5 +38,13 @@ "createClient": "Krijo klient", "searchInputPlaceholder": "Kërko sipas emrit ose emailit", "hoursPerDayValidationMessage": "Orët në ditë duhet të jenë një numër midis 1 dhe 24", - "noPermission": "Nuk ka leje" + "workingDaysValidationMessage": "Ditët e punës duhet të jenë një numër pozitiv", + "manDaysValidationMessage": "Ditët e punëtorëve duhet të jenë një numër pozitiv", + "noPermission": "Nuk ka leje", + "progressSettings": "Cilësimet e Progresit", + "manualProgress": "Progresi Manual", + "manualProgressTooltip": "Lejo përditësimet manuale të progresit për detyrat pa nëndetyra", + "weightedProgress": "Progresi i Ponderuar", + "weightedProgressTooltip": "Llogarit progresin bazuar në peshat e nëndetyrave", + "timeProgress": "Progresi i Bazuar në Kohë" } diff --git a/worklenz-frontend/public/locales/alb/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/alb/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..aff350b6 --- /dev/null +++ b/worklenz-frontend/public/locales/alb/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,34 @@ +{ + "recurring": "Përsëritës", + "recurringTaskConfiguration": "Konfigurimi i detyrës përsëritëse", + "repeats": "Përsëritet", + "daily": "Ditore", + "weekly": "Javore", + "everyXDays": "Çdo X ditë", + "everyXWeeks": "Çdo X javë", + "everyXMonths": "Çdo X muaj", + "monthly": "Mujore", + "selectDaysOfWeek": "Zgjidh ditët e javës", + "mon": "Hën", + "tue": "Mar", + "wed": "Mër", + "thu": "Enj", + "fri": "Pre", + "sat": "Sht", + "sun": "Die", + "monthlyRepeatType": "Lloji i përsëritjes mujore", + "onSpecificDate": "Në një datë specifike", + "onSpecificDay": "Në një ditë specifike", + "dateOfMonth": "Data e muajit", + "weekOfMonth": "Java e muajit", + "dayOfWeek": "Dita e javës", + "first": "E para", + "second": "E dyta", + "third": "E treta", + "fourth": "E katërta", + "last": "E fundit", + "intervalDays": "Intervali (ditë)", + "intervalWeeks": "Intervali (javë)", + "intervalMonths": "Intervali (muaj)", + "saveChanges": "Ruaj ndryshimet" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/alb/time-report.json b/worklenz-frontend/public/locales/alb/time-report.json index 8a0bb69b..027337d6 100644 --- a/worklenz-frontend/public/locales/alb/time-report.json +++ b/worklenz-frontend/public/locales/alb/time-report.json @@ -5,6 +5,7 @@ "searchByName": "Kërko sipas emrit", "selectAll": "Zgjidh të Gjitha", + "clearAll": "Pastro të Gjitha", "teams": "Ekipet", "searchByProject": "Kërko sipas emrit të projektit", @@ -15,6 +16,8 @@ "billable": "Fakturueshme", "nonBillable": "Jo Fakturueshme", + "allBillableTypes": "Të Gjitha Llojet e Fakturueshme", + "filterByBillableStatus": "Filtro sipas statusit të fakturueshmërisë", "total": "Total", @@ -28,6 +31,9 @@ "membersTimeSheet": "Fletë Kohore e Anëtarëve", "member": "Anëtar", + "members": "Anëtarët", + "searchByMember": "Kërko sipas anëtarit", + "utilization": "Përdorimi", "estimatedVsActual": "Vlerësuar vs Aktual", "workingDays": "Ditë Pune", @@ -40,5 +46,17 @@ "noCategory": "Pa Kategori", "noProjects": "Nuk u gjetën projekte", "noTeams": "Nuk u gjetën ekipe", - "noData": "Nuk u gjetën të dhëna" + "noData": "Nuk u gjetën të dhëna", + "groupBy": "Gruppo sipas", + "groupByCategory": "Kategori", + "groupByTeam": "Ekip", + "groupByStatus": "Status", + "groupByNone": "Asnjë", + "clearSearch": "Pastro kërkimin", + "selectedProjects": "Projektet e Zgjedhura", + "projectsSelected": "projekte të zgjedhura", + "showSelected": "Shfaq Vetëm të Zgjedhurat", + "expandAll": "Zgjero të Gjitha", + "collapseAll": "Mbyll të Gjitha", + "ungrouped": "Pa Grupuar" } diff --git a/worklenz-frontend/public/locales/de/project-drawer.json b/worklenz-frontend/public/locales/de/project-drawer.json index d20f220b..ea9fa2c9 100644 --- a/worklenz-frontend/public/locales/de/project-drawer.json +++ b/worklenz-frontend/public/locales/de/project-drawer.json @@ -38,5 +38,13 @@ "createClient": "Kunde erstellen", "searchInputPlaceholder": "Nach Name oder E-Mail suchen", "hoursPerDayValidationMessage": "Stunden pro Tag müssen zwischen 1 und 24", - "noPermission": "Keine Berechtigung" + "workingDaysValidationMessage": "Arbeitstage müssen eine positive Zahl sein", + "manDaysValidationMessage": "Personentage müssen eine positive Zahl sein", + "noPermission": "Keine Berechtigung", + "progressSettings": "Fortschrittseinstellungen", + "manualProgress": "Manueller Fortschritt", + "manualProgressTooltip": "Manuelle Fortschrittsaktualisierungen für Aufgaben ohne Unteraufgaben erlauben", + "weightedProgress": "Gewichteter Fortschritt", + "weightedProgressTooltip": "Fortschritt basierend auf Unteraufgaben-Gewichten berechnen", + "timeProgress": "Zeitbasierter Fortschritt" } diff --git a/worklenz-frontend/public/locales/de/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/de/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..acb4f375 --- /dev/null +++ b/worklenz-frontend/public/locales/de/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,34 @@ +{ + "recurring": "Wiederkehrend", + "recurringTaskConfiguration": "Wiederkehrende Aufgabenkonfiguration", + "repeats": "Wiederholt sich", + "daily": "Täglich", + "weekly": "Wöchentlich", + "everyXDays": "Alle X Tage", + "everyXWeeks": "Alle X Wochen", + "everyXMonths": "Alle X Monate", + "monthly": "Monatlich", + "selectDaysOfWeek": "Wochentage auswählen", + "mon": "Mo", + "tue": "Di", + "wed": "Mi", + "thu": "Do", + "fri": "Fr", + "sat": "Sa", + "sun": "So", + "monthlyRepeatType": "Monatlicher Wiederholungstyp", + "onSpecificDate": "An einem bestimmten Datum", + "onSpecificDay": "An einem bestimmten Tag", + "dateOfMonth": "Datum des Monats", + "weekOfMonth": "Woche des Monats", + "dayOfWeek": "Wochentag", + "first": "Erste", + "second": "Zweite", + "third": "Dritte", + "fourth": "Vierte", + "last": "Letzte", + "intervalDays": "Intervall (Tage)", + "intervalWeeks": "Intervall (Wochen)", + "intervalMonths": "Intervall (Monate)", + "saveChanges": "Änderungen speichern" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/de/time-report.json b/worklenz-frontend/public/locales/de/time-report.json index efadbb8a..20a9f223 100644 --- a/worklenz-frontend/public/locales/de/time-report.json +++ b/worklenz-frontend/public/locales/de/time-report.json @@ -5,6 +5,7 @@ "searchByName": "Nach Namen suchen", "selectAll": "Alle auswählen", + "clearAll": "Alle löschen", "teams": "Teams", "searchByProject": "Nach Projektnamen suchen", @@ -15,6 +16,8 @@ "billable": "Abrechenbar", "nonBillable": "Nicht abrechenbar", + "allBillableTypes": "Alle Abrechnungsarten", + "filterByBillableStatus": "Nach abrechenbarem Status filtern", "total": "Gesamt", @@ -28,6 +31,9 @@ "membersTimeSheet": "Mitglieder-Zeiterfassung", "member": "Mitglied", + "members": "Mitglieder", + "searchByMember": "Nach Mitglied suchen", + "utilization": "Auslastung", "estimatedVsActual": "Geschätzt vs. Tatsächlich", "workingDays": "Arbeitstage", @@ -40,5 +46,17 @@ "noCategory": "Keine Kategorie", "noProjects": "Keine Projekte gefunden", "noTeams": "Keine Teams gefunden", - "noData": "Keine Daten gefunden" + "noData": "Keine Daten gefunden", + "groupBy": "Gruppieren nach", + "groupByCategory": "Kategorie", + "groupByTeam": "Team", + "groupByStatus": "Status", + "groupByNone": "Keine", + "clearSearch": "Suche löschen", + "selectedProjects": "Ausgewählte Projekte", + "projectsSelected": "Projekte ausgewählt", + "showSelected": "Nur Ausgewählte anzeigen", + "expandAll": "Alle erweitern", + "collapseAll": "Alle einklappen", + "ungrouped": "Nicht gruppiert" } diff --git a/worklenz-frontend/public/locales/en/time-report.json b/worklenz-frontend/public/locales/en/time-report.json index 00aa3c7f..f805324b 100644 --- a/worklenz-frontend/public/locales/en/time-report.json +++ b/worklenz-frontend/public/locales/en/time-report.json @@ -5,6 +5,7 @@ "searchByName": "Search by name", "selectAll": "Select All", + "clearAll": "Clear All", "teams": "Teams", "searchByProject": "Search by project name", @@ -15,6 +16,8 @@ "billable": "Billable", "nonBillable": "Non Billable", + "allBillableTypes": "All Billable Types", + "filterByBillableStatus": "Filter by billable status", "total": "Total", @@ -28,6 +31,9 @@ "membersTimeSheet": "Members Time Sheet", "member": "Member", + "members": "Members", + "searchByMember": "Search by member", + "utilization": "Utilization", "estimatedVsActual": "Estimated vs Actual", "workingDays": "Working Days", diff --git a/worklenz-frontend/public/locales/es/time-report.json b/worklenz-frontend/public/locales/es/time-report.json index 2646520f..defde12d 100644 --- a/worklenz-frontend/public/locales/es/time-report.json +++ b/worklenz-frontend/public/locales/es/time-report.json @@ -5,6 +5,7 @@ "searchByName": "Buscar por nombre", "selectAll": "Seleccionar Todo", + "clearAll": "Limpiar Todo", "teams": "Equipos", "searchByProject": "Buscar por nombre del proyecto", @@ -15,6 +16,8 @@ "billable": "Facturable", "nonBillable": "No Facturable", + "allBillableTypes": "Todos los Tipos Facturables", + "filterByBillableStatus": "Filtrar por estado facturable", "total": "Total", @@ -28,6 +31,9 @@ "membersTimeSheet": "Hoja de Tiempo de Miembros", "member": "Miembro", + "members": "Miembros", + "searchByMember": "Buscar por miembro", + "utilization": "Utilización", "estimatedVsActual": "Estimado vs Real", "workingDays": "Días Laborables", diff --git a/worklenz-frontend/public/locales/pt/time-report.json b/worklenz-frontend/public/locales/pt/time-report.json index b40546e9..20520612 100644 --- a/worklenz-frontend/public/locales/pt/time-report.json +++ b/worklenz-frontend/public/locales/pt/time-report.json @@ -5,6 +5,7 @@ "searchByName": "Pesquisar por nome", "selectAll": "Selecionar Tudo", + "clearAll": "Limpar Tudo", "teams": "Equipes", "searchByProject": "Pesquisar por nome do projeto", @@ -15,6 +16,8 @@ "billable": "Faturável", "nonBillable": "Não Faturável", + "allBillableTypes": "Todos os Tipos Faturáveis", + "filterByBillableStatus": "Filtrar por status faturável", "total": "Total", @@ -28,6 +31,9 @@ "membersTimeSheet": "Folha de Tempo de Membros", "member": "Membro", + "members": "Membros", + "searchByMember": "Pesquisar por membro", + "utilization": "Utilização", "estimatedVsActual": "Estimado vs Real", "workingDays": "Dias Úteis", diff --git a/worklenz-frontend/public/locales/zh/kanban-board.json b/worklenz-frontend/public/locales/zh/kanban-board.json index 7b72c5d5..8cf20031 100644 --- a/worklenz-frontend/public/locales/zh/kanban-board.json +++ b/worklenz-frontend/public/locales/zh/kanban-board.json @@ -15,5 +15,15 @@ "assignToMe": "分配给我", "archive": "归档", "newTaskNamePlaceholder": "写一个任务名称", - "newSubtaskNamePlaceholder": "写一个子任务名称" + "newSubtaskNamePlaceholder": "写一个子任务名称", + "untitledSection": "无标题部分", + "unmapped": "未映射", + "clickToChangeDate": "点击更改日期", + "noDueDate": "无截止日期", + "save": "保存", + "clear": "清除", + "nextWeek": "下周", + "noSubtasks": "无子任务", + "showSubtasks": "显示子任务", + "hideSubtasks": "隐藏子任务" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/project-drawer.json b/worklenz-frontend/public/locales/zh/project-drawer.json index 1649dfde..82ba1f50 100644 --- a/worklenz-frontend/public/locales/zh/project-drawer.json +++ b/worklenz-frontend/public/locales/zh/project-drawer.json @@ -38,5 +38,13 @@ "createClient": "创建客户", "searchInputPlaceholder": "按名称或电子邮件搜索", "hoursPerDayValidationMessage": "每天小时数必须是1到24之间的数字", - "noPermission": "无权限" + "workingDaysValidationMessage": "工作日必须是正数", + "manDaysValidationMessage": "人天必须是正数", + "noPermission": "无权限", + "progressSettings": "进度设置", + "manualProgress": "手动进度", + "manualProgressTooltip": "允许对没有子任务的任务进行手动进度更新", + "weightedProgress": "加权进度", + "weightedProgressTooltip": "基于子任务权重计算进度", + "timeProgress": "基于时间的进度" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/zh/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..3a550e25 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,34 @@ +{ + "recurring": "重复", + "recurringTaskConfiguration": "重复任务配置", + "repeats": "重复", + "daily": "每日", + "weekly": "每周", + "everyXDays": "每X天", + "everyXWeeks": "每X周", + "everyXMonths": "每X月", + "monthly": "每月", + "selectDaysOfWeek": "选择星期几", + "mon": "周一", + "tue": "周二", + "wed": "周三", + "thu": "周四", + "fri": "周五", + "sat": "周六", + "sun": "周日", + "monthlyRepeatType": "每月重复类型", + "onSpecificDate": "在特定日期", + "onSpecificDay": "在特定星期几", + "dateOfMonth": "月份日期", + "weekOfMonth": "月份周数", + "dayOfWeek": "星期几", + "first": "第一", + "second": "第二", + "third": "第三", + "fourth": "第四", + "last": "最后", + "intervalDays": "间隔(天)", + "intervalWeeks": "间隔(周)", + "intervalMonths": "间隔(月)", + "saveChanges": "保存更改" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/time-report.json b/worklenz-frontend/public/locales/zh/time-report.json index c376954a..941e642a 100644 --- a/worklenz-frontend/public/locales/zh/time-report.json +++ b/worklenz-frontend/public/locales/zh/time-report.json @@ -4,6 +4,7 @@ "timeSheet": "时间表", "searchByName": "按名称搜索", "selectAll": "全选", + "clearAll": "清除全部", "teams": "团队", "searchByProject": "按项目名称搜索", "projects": "项目", @@ -11,6 +12,8 @@ "categories": "类别", "billable": "可计费", "nonBillable": "不可计费", + "allBillableTypes": "所有计费类型", + "filterByBillableStatus": "按计费状态筛选", "total": "总计", "projectsTimeSheet": "项目时间表", "loggedTime": "已记录时间(小时)", @@ -19,6 +22,9 @@ "for": "为", "membersTimeSheet": "成员时间表", "member": "成员", + "members": "成员", + "searchByMember": "按成员搜索", + "utilization": "利用率", "estimatedVsActual": "预计用时 vs 实际用时", "workingDays": "工作日", "manDays": "人天", @@ -29,5 +35,17 @@ "noCategory": "无类别", "noProjects": "未找到项目", "noTeams": "未找到团队", - "noData": "未找到数据" + "noData": "未找到数据", + "groupBy": "分组方式", + "groupByCategory": "类别", + "groupByTeam": "团队", + "groupByStatus": "状态", + "groupByNone": "无", + "clearSearch": "清除搜索", + "selectedProjects": "已选项目", + "projectsSelected": "个项目已选择", + "showSelected": "仅显示已选择", + "expandAll": "全部展开", + "collapseAll": "全部折叠", + "ungrouped": "未分组" } \ No newline at end of file diff --git a/worklenz-frontend/src/components/reporting/time-reports/page-header/Billable.tsx b/worklenz-frontend/src/components/reporting/time-reports/page-header/Billable.tsx new file mode 100644 index 00000000..5b273132 --- /dev/null +++ b/worklenz-frontend/src/components/reporting/time-reports/page-header/Billable.tsx @@ -0,0 +1,258 @@ +import { setSelectOrDeselectBillable } from '@/features/reporting/time-reports/time-reports-overview.slice'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { + Button, + Checkbox, + Dropdown, + MenuProps, + Space, + Divider, + theme, + CaretDownFilled, + FilterOutlined, + CheckCircleFilled, +} from '@/shared/antd-imports'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +const Billable: React.FC = () => { + const { t } = useTranslation('time-report'); + const dispatch = useAppDispatch(); + const { token } = theme.useToken(); + + const { billable } = useAppSelector(state => state.timeReportsOverviewReducer); + + // Calculate active filters count + const activeFiltersCount = useMemo(() => { + let count = 0; + if (billable.billable) count++; + if (billable.nonBillable) count++; + return count; + }, [billable.billable, billable.nonBillable]); + + // Check if all options are selected + const isAllSelected = billable.billable && billable.nonBillable; + const isNoneSelected = !billable.billable && !billable.nonBillable; + + // Handle select all + const handleSelectAll = () => { + dispatch( + setSelectOrDeselectBillable({ + billable: true, + nonBillable: true, + }) + ); + }; + + // Handle clear all + const handleClearAll = () => { + dispatch( + setSelectOrDeselectBillable({ + billable: false, + nonBillable: false, + }) + ); + }; + + // Theme-aware colors matching improved task filters + const isDark = token.colorBgContainer !== '#ffffff'; + const colors = { + headerText: isDark ? '#8c8c8c' : '#595959', + borderColor: isDark ? '#404040' : '#f0f0f0', + linkActive: isDark ? '#d9d9d9' : '#1890ff', + linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9', + successColor: isDark ? '#52c41a' : '#52c41a', + errorColor: isDark ? '#ff4d4f' : '#ff4d4f', + buttonBorder: isDark ? '#303030' : '#d9d9d9', + buttonText: activeFiltersCount > 0 + ? (isDark ? 'white' : '#262626') + : (isDark ? '#d9d9d9' : '#595959'), + buttonBg: activeFiltersCount > 0 + ? (isDark ? '#434343' : '#f5f5f5') + : (isDark ? '#141414' : 'white'), + dropdownBg: isDark ? '#1f1f1f' : 'white', + dropdownBorder: isDark ? '#303030' : '#d9d9d9', + }; + + // Dropdown items for the menu + const menuItems: MenuProps['items'] = [ + { + key: 'header', + label: ( +
+ {t('filterByBillableStatus')} +
+ ), + disabled: true, + }, + { + key: 'actions', + label: ( +
+ + + + + +
+ ), + disabled: true, + }, + { + key: 'billable', + label: ( +
+ + {t('billable')} + + {billable.billable && ( + + )} +
+ ), + onClick: () => { + dispatch(setSelectOrDeselectBillable({ ...billable, billable: !billable.billable })); + }, + }, + { + key: 'nonBillable', + label: ( +
+ + {t('nonBillable')} + + {billable.nonBillable && ( + + )} +
+ ), + onClick: () => { + dispatch(setSelectOrDeselectBillable({ ...billable, nonBillable: !billable.nonBillable })); + }, + }, + ]; + + // Button text based on selection state + const getButtonText = () => { + if (isNoneSelected) return t('billable'); + if (isAllSelected) return t('allBillableTypes'); + if (billable.billable && !billable.nonBillable) return t('billable'); + if (!billable.billable && billable.nonBillable) return t('nonBillable'); + return t('billable'); + }; + + return ( +
+ + + +
+ ); +}; + +export default Billable; diff --git a/worklenz-frontend/src/components/reporting/time-reports/page-header/Categories.tsx b/worklenz-frontend/src/components/reporting/time-reports/page-header/Categories.tsx new file mode 100644 index 00000000..31f7b09b --- /dev/null +++ b/worklenz-frontend/src/components/reporting/time-reports/page-header/Categories.tsx @@ -0,0 +1,317 @@ +import { + fetchReportingProjects, + setNoCategory, + setSelectOrDeselectAllCategories, + setSelectOrDeselectCategory, +} from '@/features/reporting/time-reports/time-reports-overview.slice'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { + Button, + Checkbox, + Divider, + Dropdown, + Input, + theme, + Space, + CaretDownFilled, + FilterOutlined, + CheckCircleFilled, + CheckboxChangeEvent +} from '@/shared/antd-imports'; +import React, { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +const Categories: React.FC = () => { + const dispatch = useAppDispatch(); + + const [searchText, setSearchText] = useState(''); + const [selectAll, setSelectAll] = useState(true); + const { t } = useTranslation('time-report'); + const [dropdownVisible, setDropdownVisible] = useState(false); + const { categories, loadingCategories, noCategory } = useAppSelector( + state => state.timeReportsOverviewReducer + ); + const { token } = theme.useToken(); + + // Calculate active filters count + const activeFiltersCount = useMemo(() => { + const selectedCategories = categories.filter(category => category.selected).length; + return selectedCategories + (noCategory ? 1 : 0); + }, [categories, noCategory]); + + // Check if all options are selected + const isAllSelected = + categories.length > 0 && categories.every(category => category.selected) && noCategory; + const isNoneSelected = + categories.length > 0 && !categories.some(category => category.selected) && !noCategory; + + const filteredItems = categories.filter(item => + item.name?.toLowerCase().includes(searchText.toLowerCase()) + ); + + // Theme-aware colors matching improved task filters + const isDark = token.colorBgContainer !== '#ffffff'; + const colors = { + headerText: isDark ? '#8c8c8c' : '#595959', + borderColor: isDark ? '#404040' : '#f0f0f0', + linkActive: isDark ? '#d9d9d9' : '#1890ff', + linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9', + successColor: isDark ? '#52c41a' : '#52c41a', + errorColor: isDark ? '#ff4d4f' : '#ff4d4f', + buttonBorder: isDark ? '#303030' : '#d9d9d9', + buttonText: activeFiltersCount > 0 + ? (isDark ? 'white' : '#262626') + : (isDark ? '#d9d9d9' : '#595959'), + buttonBg: activeFiltersCount > 0 + ? (isDark ? '#434343' : '#f5f5f5') + : (isDark ? '#141414' : 'white'), + dropdownBg: isDark ? '#1f1f1f' : 'white', + dropdownBorder: isDark ? '#303030' : '#d9d9d9', + }; + + // Handle checkbox change for individual items + const handleCheckboxChange = async (key: string, checked: boolean) => { + await dispatch(setSelectOrDeselectCategory({ id: key, selected: checked })); + await dispatch(fetchReportingProjects()); + }; + + // Handle "Select All" checkbox change + const handleSelectAllChange = async (e: CheckboxChangeEvent) => { + const isChecked = e.target.checked; + setSelectAll(isChecked); + await dispatch(setNoCategory(isChecked)); + await dispatch(setSelectOrDeselectAllCategories(isChecked)); + await dispatch(fetchReportingProjects()); + }; + + // Handle select all button click + const handleSelectAllClick = async () => { + const newValue = !isAllSelected; + setSelectAll(newValue); + await dispatch(setNoCategory(newValue)); + await dispatch(setSelectOrDeselectAllCategories(newValue)); + await dispatch(fetchReportingProjects()); + }; + + // Handle clear all + const handleClearAll = async () => { + setSelectAll(false); + await dispatch(setNoCategory(false)); + await dispatch(setSelectOrDeselectAllCategories(false)); + await dispatch(fetchReportingProjects()); + }; + + const handleNoCategoryChange = async (checked: boolean) => { + await dispatch(setNoCategory(checked)); + await dispatch(fetchReportingProjects()); + }; + + const getButtonText = () => { + if (isNoneSelected) return t('categories'); + if (isAllSelected) return `All ${t('categories')}`; + return `${t('categories')} (${activeFiltersCount})`; + }; + + return ( +
+ ( +
+ {/* Header */} +
+ {t('searchByCategory')} +
+ + {/* Search */} +
+ e.stopPropagation()} + placeholder={t('searchByCategory')} + value={searchText} + onChange={e => setSearchText(e.target.value)} + style={{ fontSize: '14px' }} + /> +
+ + {/* Actions */} + {categories.length > 0 && ( +
+ + + + + +
+ )} + + {/* No Category Option */} +
+ e.stopPropagation()} + checked={noCategory} + onChange={e => handleNoCategoryChange(e.target.checked)} + style={{ fontSize: '14px' }} + > + {t('noCategory')} + + {noCategory && ( + + )} +
+ + + + {/* Items */} +
+ {filteredItems.length > 0 ? ( + filteredItems.map(item => ( +
+ e.stopPropagation()} + checked={item.selected} + onChange={e => handleCheckboxChange(item.id || '', e.target.checked)} + style={{ fontSize: '14px' }} + > + {item.name} + + {item.selected && ( + + )} +
+ )) + ) : ( +
+ {t('noCategories')} +
+ )} +
+
+ )} + onOpenChange={visible => { + setDropdownVisible(visible); + if (!visible) { + setSearchText(''); + } + }} + > + +
+
+ ); +}; + +export default Categories; diff --git a/worklenz-frontend/src/components/reporting/time-reports/page-header/Members.tsx b/worklenz-frontend/src/components/reporting/time-reports/page-header/Members.tsx new file mode 100644 index 00000000..066d840a --- /dev/null +++ b/worklenz-frontend/src/components/reporting/time-reports/page-header/Members.tsx @@ -0,0 +1,263 @@ +import React, { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + Checkbox, + Divider, + Dropdown, + Input, + Avatar, + theme, + Space, + CaretDownFilled, + FilterOutlined, + CheckCircleFilled, + CheckboxChangeEvent, +} from '@/shared/antd-imports'; + +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { + setSelectOrDeselectAllMembers, + setSelectOrDeselectMember, +} from '@/features/reporting/time-reports/time-reports-overview.slice'; + +const Members: React.FC = () => { + const dispatch = useAppDispatch(); + const { t } = useTranslation('time-report'); + const { members, loadingMembers } = useAppSelector(state => state.timeReportsOverviewReducer); + const { token } = theme.useToken(); + + const [searchText, setSearchText] = useState(''); + const [selectAll, setSelectAll] = useState(true); + + // Calculate active filters count + const activeFiltersCount = useMemo(() => { + return members.filter(member => member.selected).length; + }, [members]); + + // Check if all options are selected + const isAllSelected = members.length > 0 && members.every(member => member.selected); + const isNoneSelected = members.length > 0 && !members.some(member => member.selected); + + // Filter members based on search text + const filteredMembers = members.filter(member => + member.name?.toLowerCase().includes(searchText.toLowerCase()) + ); + + // Theme-aware colors matching improved task filters + const isDark = token.colorBgContainer !== '#ffffff'; + const colors = { + headerText: isDark ? '#8c8c8c' : '#595959', + borderColor: isDark ? '#404040' : '#f0f0f0', + linkActive: isDark ? '#d9d9d9' : '#1890ff', + linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9', + successColor: isDark ? '#52c41a' : '#52c41a', + errorColor: isDark ? '#ff4d4f' : '#ff4d4f', + buttonBorder: isDark ? '#303030' : '#d9d9d9', + buttonText: activeFiltersCount > 0 + ? (isDark ? 'white' : '#262626') + : (isDark ? '#d9d9d9' : '#595959'), + buttonBg: activeFiltersCount > 0 + ? (isDark ? '#434343' : '#f5f5f5') + : (isDark ? '#141414' : 'white'), + dropdownBg: isDark ? '#1f1f1f' : 'white', + dropdownBorder: isDark ? '#303030' : '#d9d9d9', + }; + + // Handle checkbox change for individual members + const handleCheckboxChange = (id: string, checked: boolean) => { + dispatch(setSelectOrDeselectMember({ id, selected: checked })); + }; + + // Handle "Select All" checkbox change + const handleSelectAllChange = (e: CheckboxChangeEvent) => { + const isChecked = e.target.checked; + setSelectAll(isChecked); + dispatch(setSelectOrDeselectAllMembers(isChecked)); + }; + + // Handle select all button click + const handleSelectAllClick = () => { + const newValue = !isAllSelected; + setSelectAll(newValue); + dispatch(setSelectOrDeselectAllMembers(newValue)); + }; + + // Handle clear all + const handleClearAll = () => { + setSelectAll(false); + dispatch(setSelectOrDeselectAllMembers(false)); + }; + + const getButtonText = () => { + if (isNoneSelected) return t('members'); + if (isAllSelected) return `All ${t('members')}`; + return `${t('members')} (${activeFiltersCount})`; + }; + + return ( + ( +
+ {/* Header */} +
+ {t('searchByMember')} +
+ + {/* Search */} +
+ e.stopPropagation()} + placeholder={t('searchByMember')} + value={searchText} + onChange={e => setSearchText(e.target.value)} + style={{ fontSize: '14px' }} + /> +
+ + {/* Actions */} +
+ + + + + +
+ + + + {/* Items */} +
+ {filteredMembers.map(member => ( +
+ + e.stopPropagation()} + checked={member.selected} + onChange={e => handleCheckboxChange(member.id, e.target.checked)} + style={{ fontSize: '14px' }} + > + {member.name} + + {member.selected && ( + + )} +
+ ))} +
+
+ )} + > + +
+ ); +}; + +export default Members; diff --git a/worklenz-frontend/src/components/reporting/time-reports/page-header/Projects.tsx b/worklenz-frontend/src/components/reporting/time-reports/page-header/Projects.tsx new file mode 100644 index 00000000..33f7ec35 --- /dev/null +++ b/worklenz-frontend/src/components/reporting/time-reports/page-header/Projects.tsx @@ -0,0 +1,267 @@ +import React, { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + Checkbox, + Divider, + Dropdown, + Input, + theme, + Space, + CaretDownFilled, + FilterOutlined, + CheckCircleFilled, + CheckboxChangeEvent, +} from '@/shared/antd-imports'; +import { + setSelectOrDeselectAllProjects, + setSelectOrDeselectProject, +} from '@/features/reporting/time-reports/time-reports-overview.slice'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; + +const Projects: React.FC = () => { + const dispatch = useAppDispatch(); + const [checkedList, setCheckedList] = useState([]); + const [searchText, setSearchText] = useState(''); + const [selectAll, setSelectAll] = useState(true); + const { t } = useTranslation('time-report'); + const [dropdownVisible, setDropdownVisible] = useState(false); + const { projects, loadingProjects } = useAppSelector(state => state.timeReportsOverviewReducer); + const { token } = theme.useToken(); + + // Calculate active filters count + const activeFiltersCount = useMemo(() => { + return projects.filter(project => project.selected).length; + }, [projects]); + + // Check if all options are selected + const isAllSelected = projects.length > 0 && projects.every(project => project.selected); + const isNoneSelected = projects.length > 0 && !projects.some(project => project.selected); + + // Filter items based on search text + const filteredItems = projects.filter(item => + item.name?.toLowerCase().includes(searchText.toLowerCase()) + ); + + // Theme-aware colors matching improved task filters + const isDark = token.colorBgContainer !== '#ffffff'; + const colors = { + headerText: isDark ? '#8c8c8c' : '#595959', + borderColor: isDark ? '#404040' : '#f0f0f0', + linkActive: isDark ? '#d9d9d9' : '#1890ff', + linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9', + successColor: isDark ? '#52c41a' : '#52c41a', + errorColor: isDark ? '#ff4d4f' : '#ff4d4f', + buttonBorder: isDark ? '#303030' : '#d9d9d9', + buttonText: activeFiltersCount > 0 + ? (isDark ? 'white' : '#262626') + : (isDark ? '#d9d9d9' : '#595959'), + buttonBg: activeFiltersCount > 0 + ? (isDark ? '#434343' : '#f5f5f5') + : (isDark ? '#141414' : 'white'), + dropdownBg: isDark ? '#1f1f1f' : 'white', + dropdownBorder: isDark ? '#303030' : '#d9d9d9', + }; + + // Handle checkbox change for individual items + const handleCheckboxChange = (key: string, checked: boolean) => { + dispatch(setSelectOrDeselectProject({ id: key, selected: checked })); + }; + + // Handle "Select All" checkbox change + const handleSelectAllChange = (e: CheckboxChangeEvent) => { + const isChecked = e.target.checked; + setSelectAll(isChecked); + dispatch(setSelectOrDeselectAllProjects(isChecked)); + }; + + // Handle select all button click + const handleSelectAllClick = () => { + const newValue = !isAllSelected; + setSelectAll(newValue); + dispatch(setSelectOrDeselectAllProjects(newValue)); + }; + + // Handle clear all + const handleClearAll = () => { + setSelectAll(false); + dispatch(setSelectOrDeselectAllProjects(false)); + }; + + const getButtonText = () => { + if (isNoneSelected) return t('projects'); + if (isAllSelected) return `All ${t('projects')}`; + return `${t('projects')} (${activeFiltersCount})`; + }; + + return ( +
+ ( +
+ {/* Header */} +
+ {t('searchByProject')} +
+ + {/* Search */} +
+ e.stopPropagation()} + placeholder={t('searchByProject')} + value={searchText} + onChange={e => setSearchText(e.target.value)} + style={{ fontSize: '14px' }} + /> +
+ + {/* Actions */} +
+ + + + + +
+ + + + {/* Items */} +
+ {filteredItems.map(item => ( +
+ e.stopPropagation()} + checked={item.selected} + onChange={e => handleCheckboxChange(item.id || '', e.target.checked)} + style={{ fontSize: '14px' }} + > + {item.name} + + {item.selected && ( + + )} +
+ ))} +
+
+ )} + onOpenChange={visible => { + setDropdownVisible(visible); + if (!visible) { + setSearchText(''); + } + }} + > + +
+
+ ); +}; + +export default Projects; diff --git a/worklenz-frontend/src/components/reporting/time-reports/page-header/Team.tsx b/worklenz-frontend/src/components/reporting/time-reports/page-header/Team.tsx new file mode 100644 index 00000000..c8b24fbd --- /dev/null +++ b/worklenz-frontend/src/components/reporting/time-reports/page-header/Team.tsx @@ -0,0 +1,274 @@ +import React, { useState, useMemo } from 'react'; +import { + Button, + Checkbox, + Divider, + Dropdown, + Input, + theme, + Space, + CaretDownFilled, + FilterOutlined, + CheckCircleFilled, + CheckboxChangeEvent, +} from '@/shared/antd-imports'; +import { useTranslation } from 'react-i18next'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { + fetchReportingCategories, + fetchReportingProjects, + setSelectOrDeselectAllTeams, + setSelectOrDeselectTeam, +} from '@/features/reporting/time-reports/time-reports-overview.slice'; + +const Team: React.FC = () => { + const dispatch = useAppDispatch(); + const [searchText, setSearchText] = useState(''); + const [selectAll, setSelectAll] = useState(true); + const { t } = useTranslation('time-report'); + const [dropdownVisible, setDropdownVisible] = useState(false); + const { token } = theme.useToken(); + + const { teams, loadingTeams } = useAppSelector(state => state.timeReportsOverviewReducer); + + // Calculate active filters count + const activeFiltersCount = useMemo(() => { + return teams.filter(team => team.selected).length; + }, [teams]); + + // Check if all options are selected + const isAllSelected = teams.length > 0 && teams.every(team => team.selected); + const isNoneSelected = teams.length > 0 && !teams.some(team => team.selected); + + const filteredItems = teams.filter(item => + item.name?.toLowerCase().includes(searchText.toLowerCase()) + ); + + // Theme-aware colors matching improved task filters + const isDark = token.colorBgContainer !== '#ffffff'; + const colors = { + headerText: isDark ? '#8c8c8c' : '#595959', + borderColor: isDark ? '#404040' : '#f0f0f0', + linkActive: isDark ? '#d9d9d9' : '#1890ff', + linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9', + successColor: isDark ? '#52c41a' : '#52c41a', + errorColor: isDark ? '#ff4d4f' : '#ff4d4f', + buttonBorder: isDark ? '#303030' : '#d9d9d9', + buttonText: activeFiltersCount > 0 + ? (isDark ? 'white' : '#262626') + : (isDark ? '#d9d9d9' : '#595959'), + buttonBg: activeFiltersCount > 0 + ? (isDark ? '#434343' : '#f5f5f5') + : (isDark ? '#141414' : 'white'), + dropdownBg: isDark ? '#1f1f1f' : 'white', + dropdownBorder: isDark ? '#303030' : '#d9d9d9', + }; + + const handleCheckboxChange = async (key: string, checked: boolean) => { + dispatch(setSelectOrDeselectTeam({ id: key, selected: checked })); + await dispatch(fetchReportingCategories()); + await dispatch(fetchReportingProjects()); + }; + + const handleSelectAllChange = async (e: CheckboxChangeEvent) => { + const isChecked = e.target.checked; + setSelectAll(isChecked); + dispatch(setSelectOrDeselectAllTeams(isChecked)); + await dispatch(fetchReportingCategories()); + await dispatch(fetchReportingProjects()); + }; + + // Handle clear all + const handleClearAll = async () => { + setSelectAll(false); + dispatch(setSelectOrDeselectAllTeams(false)); + await dispatch(fetchReportingCategories()); + await dispatch(fetchReportingProjects()); + }; + + // Handle select all button click + const handleSelectAllClick = async () => { + const newValue = !isAllSelected; + setSelectAll(newValue); + dispatch(setSelectOrDeselectAllTeams(newValue)); + await dispatch(fetchReportingCategories()); + await dispatch(fetchReportingProjects()); + }; + + const getButtonText = () => { + if (isNoneSelected) return t('teams'); + if (isAllSelected) return `All ${t('teams')}`; + return `${t('teams')} (${activeFiltersCount})`; + }; + + return ( +
+ ( +
+ {/* Header */} +
+ {t('searchByName')} +
+ + {/* Search */} +
+ setSearchText(e.target.value)} + onClick={e => e.stopPropagation()} + style={{ fontSize: '14px' }} + /> +
+ + {/* Actions */} +
+ + + + + +
+ + + + {/* Items */} +
+ {filteredItems.map(item => ( +
+ e.stopPropagation()} + checked={item.selected} + onChange={e => handleCheckboxChange(item.id || '', e.target.checked)} + style={{ fontSize: '14px' }} + > + {item.name} + + {item.selected && ( + + )} +
+ ))} +
+
+ )} + onOpenChange={visible => { + setDropdownVisible(visible); + if (!visible) { + setSearchText(''); + } + }} + > + +
+
+ ); +}; + +export default Team; diff --git a/worklenz-frontend/src/components/reporting/time-reports/page-header/TimeReportPageHeader.tsx b/worklenz-frontend/src/components/reporting/time-reports/page-header/TimeReportPageHeader.tsx new file mode 100644 index 00000000..b11b85b0 --- /dev/null +++ b/worklenz-frontend/src/components/reporting/time-reports/page-header/TimeReportPageHeader.tsx @@ -0,0 +1,54 @@ + +import React, { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import Team from './Team'; +import Categories from './Categories'; +import Projects from './Projects'; +import Billable from './Billable'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { + fetchReportingTeams, + fetchReportingProjects, + fetchReportingCategories, + fetchReportingMembers, + fetchReportingUtilization, +} from '@/features/reporting/time-reports/time-reports-overview.slice'; +import Members from './Members'; +import Utilization from './Utilization'; + +const TimeReportPageHeader: React.FC = () => { + const dispatch = useAppDispatch(); + const location = useLocation(); + + // Check if current route is members time sheet + const isMembersTimeSheet = location.pathname.includes('time-sheet-members'); + + useEffect(() => { + const fetchData = async () => { + await dispatch(fetchReportingTeams()); + await dispatch(fetchReportingCategories()); + await dispatch(fetchReportingProjects()); + + // Only fetch members and utilization data for members time sheet + if (isMembersTimeSheet) { + await dispatch(fetchReportingMembers()); + await dispatch(fetchReportingUtilization()); + } + }; + + fetchData(); + }, [dispatch, isMembersTimeSheet]); + + return ( +
+ + + + + {isMembersTimeSheet && } + {isMembersTimeSheet && } +
+ ); +}; + +export default TimeReportPageHeader; diff --git a/worklenz-frontend/src/components/reporting/time-reports/page-header/Utilization.tsx b/worklenz-frontend/src/components/reporting/time-reports/page-header/Utilization.tsx new file mode 100644 index 00000000..91c07512 --- /dev/null +++ b/worklenz-frontend/src/components/reporting/time-reports/page-header/Utilization.tsx @@ -0,0 +1,251 @@ +import React, { useState, useMemo } from 'react'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { + setSelectOrDeselectAllUtilization, + setSelectOrDeselectUtilization, +} from '@/features/reporting/time-reports/time-reports-overview.slice'; +import { + Button, + Checkbox, + Divider, + Dropdown, + Input, + Avatar, + theme, + Space, + CaretDownFilled, + FilterOutlined, + CheckCircleFilled, + CheckboxChangeEvent, +} from '@/shared/antd-imports'; +import { useTranslation } from 'react-i18next'; + +const Utilization: React.FC = () => { + const dispatch = useAppDispatch(); + const { t } = useTranslation('time-report'); + const { utilization, loadingUtilization } = useAppSelector( + state => state.timeReportsOverviewReducer + ); + const { token } = theme.useToken(); + + const [searchText, setSearchText] = useState(''); + const [selectAll, setSelectAll] = useState(true); + + // Calculate active filters count + const activeFiltersCount = useMemo(() => { + return utilization.filter(item => item.selected).length; + }, [utilization]); + + // Check if all options are selected + const isAllSelected = utilization.length > 0 && utilization.every(item => item.selected); + const isNoneSelected = utilization.length > 0 && !utilization.some(item => item.selected); + + // Filter members based on search text + const filteredItems = utilization.filter(item => + item.name?.toLowerCase().includes(searchText.toLowerCase()) + ); + + // Theme-aware colors matching improved task filters + const isDark = token.colorBgContainer !== '#ffffff'; + const colors = { + headerText: isDark ? '#8c8c8c' : '#595959', + borderColor: isDark ? '#404040' : '#f0f0f0', + linkActive: isDark ? '#d9d9d9' : '#1890ff', + linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9', + successColor: isDark ? '#52c41a' : '#52c41a', + errorColor: isDark ? '#ff4d4f' : '#ff4d4f', + buttonBorder: isDark ? '#303030' : '#d9d9d9', + buttonText: activeFiltersCount > 0 + ? (isDark ? 'white' : '#262626') + : (isDark ? '#d9d9d9' : '#595959'), + buttonBg: activeFiltersCount > 0 + ? (isDark ? '#434343' : '#f5f5f5') + : (isDark ? '#141414' : 'white'), + dropdownBg: isDark ? '#1f1f1f' : 'white', + dropdownBorder: isDark ? '#303030' : '#d9d9d9', + }; + + // Handle checkbox change for individual members + const handleCheckboxChange = (id: string, selected: boolean) => { + dispatch(setSelectOrDeselectUtilization({ id, selected })); + }; + + const handleSelectAll = (e: CheckboxChangeEvent) => { + const isChecked = e.target.checked; + setSelectAll(isChecked); + dispatch(setSelectOrDeselectAllUtilization(isChecked)); + }; + + // Handle select all button click + const handleSelectAllClick = () => { + const newValue = !isAllSelected; + setSelectAll(newValue); + dispatch(setSelectOrDeselectAllUtilization(newValue)); + }; + + // Handle clear all + const handleClearAll = () => { + setSelectAll(false); + dispatch(setSelectOrDeselectAllUtilization(false)); + }; + + const getButtonText = () => { + if (isNoneSelected) return t('utilization'); + if (isAllSelected) return `All ${t('utilization')}`; + return `${t('utilization')} (${activeFiltersCount})`; + }; + + return ( + ( +
+ {/* Header */} +
+ {t('utilization')} +
+ + {/* Actions */} +
+ + + + + +
+ + + + {/* Items */} +
+ {filteredItems.map((ut, index) => ( +
+ e.stopPropagation()} + checked={ut.selected} + onChange={e => handleCheckboxChange(ut.id, e.target.checked)} + style={{ fontSize: '14px' }} + > + {ut.name} + + {ut.selected && ( + + )} +
+ ))} +
+
+ )} + > + +
+ ); +}; + +export default Utilization; diff --git a/worklenz-frontend/src/pages/reporting/timeReports/timeReportingRightHeader/TimeReportingRightHeader.tsx b/worklenz-frontend/src/components/reporting/time-reports/right-header/TimeReportingRightHeader.tsx similarity index 91% rename from worklenz-frontend/src/pages/reporting/timeReports/timeReportingRightHeader/TimeReportingRightHeader.tsx rename to worklenz-frontend/src/components/reporting/time-reports/right-header/TimeReportingRightHeader.tsx index 415527a4..8b205f52 100644 --- a/worklenz-frontend/src/pages/reporting/timeReports/timeReportingRightHeader/TimeReportingRightHeader.tsx +++ b/worklenz-frontend/src/components/reporting/time-reports/right-header/TimeReportingRightHeader.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { Button, Checkbox, Dropdown, Space, Typography } from '@/shared/antd-imports'; import { DownOutlined } from '@/shared/antd-imports'; import { useTranslation } from 'react-i18next'; -import CustomPageHeader from '../../page-header/custom-page-header'; -import TimeWiseFilter from '../../../../components/reporting/time-wise-filter'; +import TimeWiseFilter from '@/components/reporting/time-wise-filter'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppSelector } from '@/hooks/useAppSelector'; import { setArchived } from '@/features/reporting/time-reports/time-reports-overview.slice'; +import CustomPageHeader from '@/pages/reporting/page-header/custom-page-header'; interface headerState { title: string; diff --git a/worklenz-frontend/src/features/i18n/language-selector.tsx b/worklenz-frontend/src/features/i18n/language-selector.tsx index 36c88757..f11e6b8c 100644 --- a/worklenz-frontend/src/features/i18n/language-selector.tsx +++ b/worklenz-frontend/src/features/i18n/language-selector.tsx @@ -17,7 +17,7 @@ const LanguageSelector = () => { { key: 'pt', label: 'Português' }, { key: 'alb', label: 'Shqip' }, { key: 'de', label: 'Deutsch' }, - { key: 'zh_cn', label: '简体中文' }, + { key: 'zh', label: '简体中文' }, ]; const languageLabels = { @@ -26,7 +26,7 @@ const LanguageSelector = () => { pt: 'Pt', alb: 'Sq', de: 'de', - zh_cn: 'zh_cn', + zh: 'zh', }; return ( diff --git a/worklenz-frontend/src/features/i18n/localesSlice.ts b/worklenz-frontend/src/features/i18n/localesSlice.ts index 9177ad70..6bde5205 100644 --- a/worklenz-frontend/src/features/i18n/localesSlice.ts +++ b/worklenz-frontend/src/features/i18n/localesSlice.ts @@ -7,7 +7,7 @@ export enum Language { PT = 'pt', ALB = 'alb', DE = 'de', - ZH_CN = 'zh_cn', + ZH = 'zh', } export type ILanguageType = `${Language}`; diff --git a/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts b/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts index 9518495b..3f0195df 100644 --- a/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts +++ b/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts @@ -23,6 +23,12 @@ interface ITimeReportsOverviewState { billable: boolean; nonBillable: boolean; }; + + members: any[]; + loadingMembers: boolean; + + utilization: any[]; + loadingUtilization: boolean; } const initialState: ITimeReportsOverviewState = { @@ -42,6 +48,15 @@ const initialState: ITimeReportsOverviewState = { billable: true, nonBillable: true, }, + members: [], + loadingMembers: false, + + utilization: [], + loadingUtilization: false, +}; + +const selectedMembers = (state: ITimeReportsOverviewState) => { + return state.members.filter(member => member.selected).map(member => member.id) as string[]; }; const selectedTeams = (state: ITimeReportsOverviewState) => { @@ -54,6 +69,76 @@ const selectedCategories = (state: ITimeReportsOverviewState) => { .map(category => category.id) as string[]; }; +const selectedUtilization = (state: ITimeReportsOverviewState) => { + return state.utilization + .filter(utilization => utilization.selected) + .map(utilization => utilization.id) as string[]; +}; + +const allUtilization = (state: ITimeReportsOverviewState) => { + return state.utilization; +}; + +export const fetchReportingUtilization = createAsyncThunk( + 'timeReportsOverview/fetchReportingUtilization', + async (_, { rejectWithValue }) => { + try { + const utilization = [ + { id: 'under', name: 'Under-utilized (Under 90%)', selected: true }, + { id: 'optimal', name: 'Optimal-utilized (90%-110%)', selected: true }, + { id: 'over', name: 'Over-utilized (Over 110%)', selected: true }, + ]; + return utilization; + } catch (error) { + let errorMessage = 'An error occurred while fetching utilization'; + if (error instanceof Error) { + errorMessage = error.message; + } + return rejectWithValue(errorMessage); + } + } +); + +export const fetchReportingMembers = createAsyncThunk( + 'timeReportsOverview/fetchReportingMembers', + async (_, { rejectWithValue, getState }) => { + const state = getState() as { timeReportsOverviewReducer: ITimeReportsOverviewState }; + const { timeReportsOverviewReducer } = state; + + try { + // If members array is empty (initial load), fetch all members without pagination + // Otherwise, use the selected members filter + let queryParams; + if (timeReportsOverviewReducer.members.length === 0) { + // Initial load - fetch all members with a large page size to avoid pagination + queryParams = { + size: 1000, // Large number to get all members + index: 1, + search: '', + field: 'name', + order: 'asc', + }; + } else { + // Subsequent calls - use selected members + queryParams = selectedMembers(timeReportsOverviewReducer); + } + + const res = await reportingApiService.getMembers(queryParams); + if (res.done) { + return res.body; + } else { + return rejectWithValue(res.message || 'Failed to fetch members'); + } + } catch (error) { + let errorMessage = 'An error occurred while fetching members'; + if (error instanceof Error) { + errorMessage = error.message; + } + return rejectWithValue(errorMessage); + } + } +); + export const fetchReportingTeams = createAsyncThunk( 'timeReportsOverview/fetchReportingTeams', async () => { @@ -141,6 +226,34 @@ const timeReportsOverviewSlice = createSlice({ setArchived: (state, action: PayloadAction) => { state.archived = action.payload; }, + setSelectOrDeselectMember: ( + state, + action: PayloadAction<{ id: string; selected: boolean }> + ) => { + const member = state.members.find(member => member.id === action.payload.id); + if (member) { + member.selected = action.payload.selected; + } + }, + setSelectOrDeselectAllMembers: (state, action: PayloadAction) => { + state.members.forEach(member => { + member.selected = action.payload; + }); + }, + setSelectOrDeselectUtilization: ( + state, + action: PayloadAction<{ id: string; selected: boolean }> + ) => { + const utilization = state.utilization.find(u => u.id === action.payload.id); + if (utilization) { + utilization.selected = action.payload.selected; + } + }, + setSelectOrDeselectAllUtilization: (state, action: PayloadAction) => { + state.utilization.forEach(utilization => { + utilization.selected = action.payload; + }); + }, }, extraReducers: builder => { builder.addCase(fetchReportingTeams.fulfilled, (state, action) => { @@ -185,6 +298,37 @@ const timeReportsOverviewSlice = createSlice({ builder.addCase(fetchReportingProjects.rejected, state => { state.loadingProjects = false; }); + builder.addCase(fetchReportingMembers.fulfilled, (state, action) => { + const members = action.payload.members.map((member: any) => ({ + id: member.id, + name: member.name, + selected: true, + avatar_url: member.avatar_url, + email: member.email, + })); + state.members = members; + state.loadingMembers = false; + }); + + builder.addCase(fetchReportingMembers.pending, state => { + state.loadingMembers = true; + }); + + builder.addCase(fetchReportingMembers.rejected, (state, action) => { + state.loadingMembers = false; + console.error('Error fetching members:', action.payload); + }); + builder.addCase(fetchReportingUtilization.fulfilled, (state, action) => { + state.utilization = action.payload; + state.loadingUtilization = false; + }); + builder.addCase(fetchReportingUtilization.pending, state => { + state.loadingUtilization = true; + }); + builder.addCase(fetchReportingUtilization.rejected, (state, action) => { + state.loadingUtilization = false; + console.error('Error fetching utilization:', action.payload); + }); }, }); @@ -197,6 +341,10 @@ export const { setSelectOrDeselectProject, setSelectOrDeselectAllProjects, setSelectOrDeselectBillable, + setSelectOrDeselectMember, + setSelectOrDeselectAllMembers, + setSelectOrDeselectUtilization, + setSelectOrDeselectAllUtilization, setNoCategory, setArchived, } = timeReportsOverviewSlice.actions; diff --git a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx index 0232272e..dd99487e 100644 --- a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx +++ b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx @@ -15,17 +15,19 @@ import { useTranslation } from 'react-i18next'; import { reportingTimesheetApiService } from '@/api/reporting/reporting.timesheet.api.service'; import { IRPTTimeMember } from '@/types/reporting/reporting.types'; import logger from '@/utils/errorLogger'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { format } from 'date-fns'; ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels); +interface MembersTimeSheetProps { + onTotalsUpdate: (totals: { total_time_logs: string; total_estimated_hours: string; total_utilization: string }) => void; +} export interface MembersTimeSheetRef { exportChart: () => void; } -const MembersTimeSheet = forwardRef((_, ref) => { +const MembersTimeSheet = forwardRef(({ onTotalsUpdate }, ref) => { const { t } = useTranslation('time-report'); - const dispatch = useAppDispatch(); const chartRef = React.useRef>(null); const { @@ -35,8 +37,13 @@ const MembersTimeSheet = forwardRef((_, ref) => { loadingCategories, projects: filterProjects, loadingProjects, + members, + loadingMembers, + utilization, + loadingUtilization, billable, archived, + noCategory, } = useAppSelector(state => state.timeReportsOverviewReducer); const { duration, dateRange } = useAppSelector(state => state.reportingReducer); @@ -44,16 +51,40 @@ const MembersTimeSheet = forwardRef((_, ref) => { const [jsonData, setJsonData] = useState([]); const labels = Array.isArray(jsonData) ? jsonData.map(item => item.name) : []; - const dataValues = Array.isArray(jsonData) - ? jsonData.map(item => { - const loggedTimeInHours = parseFloat(item.logged_time || '0') / 3600; - return loggedTimeInHours.toFixed(2); - }) - : []; - const colors = Array.isArray(jsonData) ? jsonData.map(item => item.color_code) : []; + const dataValues = Array.isArray(jsonData) ? jsonData.map(item => { + const loggedTimeInHours = parseFloat(item.logged_time || '0') / 3600; + return loggedTimeInHours.toFixed(2); + }) : []; + const colors = Array.isArray(jsonData) ? jsonData.map(item => { + const utilizationPercent = parseFloat(item.utilization_percent || '0'); + + if (utilizationPercent < 90) { + return '#faad14'; // Orange for under-utilized (< 90%) + } else if (utilizationPercent <= 110) { + return '#52c41a'; // Green for optimal utilization (90-110%) + } else { + return '#ef4444'; // Red for over-utilized (> 110%) + } + }) : []; const themeMode = useAppSelector(state => state.themeReducer.mode); + // Helper function to format hours to "X hours Y mins" + const formatHours = (decimalHours: number) => { + const wholeHours = Math.floor(decimalHours); + const minutes = Math.round((decimalHours - wholeHours) * 60); + + if (wholeHours === 0 && minutes === 0) { + return '0 mins'; + } else if (wholeHours === 0) { + return `${minutes} mins`; + } else if (minutes === 0) { + return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'}`; + } else { + return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'} ${minutes} mins`; + } + }; + // Chart data const data = { labels, @@ -78,27 +109,96 @@ const MembersTimeSheet = forwardRef((_, ref) => { offset: 20, textStrokeColor: 'black', textStrokeWidth: 4, + formatter: function(value: string) { + const hours = parseFloat(value); + const wholeHours = Math.floor(hours); + const minutes = Math.round((hours - wholeHours) * 60); + + if (wholeHours === 0 && minutes === 0) { + return '0 mins'; + } else if (wholeHours === 0) { + return `${minutes} mins`; + } else if (minutes === 0) { + return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'}`; + } else { + return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'} ${minutes} mins`; + } + }, }, legend: { display: false, position: 'top' as const, }, tooltip: { + // Basic styling + backgroundColor: themeMode === 'dark' ? 'rgba(0, 0, 0, 0.9)' : 'rgba(255, 255, 255, 0.9)', + titleColor: themeMode === 'dark' ? '#ffffff' : '#000000', + bodyColor: themeMode === 'dark' ? '#ffffff' : '#000000', + borderColor: themeMode === 'dark' ? '#4a5568' : '#e2e8f0', + cornerRadius: 8, + padding: 12, + + // Remove colored squares + displayColors: false, + + // Positioning - better alignment for horizontal bar chart + xAlign: 'left' as const, + yAlign: 'center' as const, + callbacks: { + // Customize the title (member name) + title: function (context: any) { + const idx = context[0].dataIndex; + const member = jsonData[idx]; + return `👤 ${member?.name || 'Unknown Member'}`; + }, + + // Customize the label content label: function (context: any) { const idx = context.dataIndex; const member = jsonData[idx]; - const hours = member?.utilized_hours || '0.00'; - const percent = member?.utilization_percent || '0.00'; - const overUnder = member?.over_under_utilized_hours || '0.00'; + const hours = parseFloat(member?.utilized_hours || '0'); + const percent = parseFloat(member?.utilization_percent || '0.00'); + const overUnder = parseFloat(member?.over_under_utilized_hours || '0'); + + // Color indicators based on utilization state + let statusText = ''; + let criteriaText = ''; + switch (member.utilization_state) { + case 'under': + statusText = '🟠 Under-Utilized'; + criteriaText = '(< 90%)'; + break; + case 'optimal': + statusText = '🟢 Optimally Utilized'; + criteriaText = '(90% - 110%)'; + break; + case 'over': + statusText = '🔴 Over-Utilized'; + criteriaText = '(> 110%)'; + break; + default: + statusText = '⚪ Unknown'; + criteriaText = ''; + } + return [ - `${context.dataset.label}: ${hours} h`, - `Utilization: ${percent}%`, - `Over/Under Utilized: ${overUnder} h`, + `⏱️ ${context.dataset.label}: ${formatHours(hours)}`, + `📊 Utilization: ${percent.toFixed(1)}%`, + `${statusText} ${criteriaText}`, + `📈 Variance: ${formatHours(Math.abs(overUnder))}${overUnder < 0 ? ' (under)' : overUnder > 0 ? ' (over)' : ''}` ]; }, - }, - }, + + // Add a footer with additional info + footer: function (context: any) { + const idx = context[0].dataIndex; + const member = jsonData[idx]; + const loggedTime = parseFloat(member?.logged_time || '0') / 3600; + return `📊 Total Logged: ${formatHours(loggedTime)}`; + } + } + } }, backgroundColor: 'black', indexAxis: 'y' as const, @@ -142,30 +242,93 @@ const MembersTimeSheet = forwardRef((_, ref) => { const selectedTeams = teams.filter(team => team.selected); const selectedProjects = filterProjects.filter(project => project.selected); const selectedCategories = categories.filter(category => category.selected); + const selectedMembers = members.filter(member => member.selected); + const selectedUtilization = utilization.filter(item => item.selected); + + // Format dates using date-fns + const formattedDateRange = dateRange ? [ + format(new Date(dateRange[0]), 'yyyy-MM-dd'), + format(new Date(dateRange[1]), 'yyyy-MM-dd') + ] : undefined; const body = { teams: selectedTeams.map(t => t.id), projects: selectedProjects.map(project => project.id), categories: selectedCategories.map(category => category.id), + members: selectedMembers.map(member => member.id), + utilization: selectedUtilization.map(item => item.id), duration, - date_range: dateRange, + date_range: formattedDateRange, billable, + noCategory, }; const res = await reportingTimesheetApiService.getMemberTimeSheets(body, archived); + if (res.done) { - setJsonData(res.body || []); + // Ensure filteredRows is always an array, even if API returns null/undefined + setJsonData(res.body?.filteredRows || []); + + const totalsRaw = res.body?.totals || {}; + const totals = { + total_time_logs: totalsRaw.total_time_logs ?? "0", + total_estimated_hours: totalsRaw.total_estimated_hours ?? "0", + total_utilization: totalsRaw.total_utilization ?? "0", + }; + onTotalsUpdate(totals); + } else { + // Handle API error case + setJsonData([]); + onTotalsUpdate({ + total_time_logs: "0", + total_estimated_hours: "0", + total_utilization: "0" + }); } } catch (error) { + console.error('Error fetching chart data:', error); logger.error('Error fetching chart data:', error); + // Reset data on error + setJsonData([]); + onTotalsUpdate({ + total_time_logs: "0", + total_estimated_hours: "0", + total_utilization: "0" + }); } finally { setLoading(false); } }; + // Create stable references for selected items to prevent unnecessary re-renders + const selectedTeamIds = React.useMemo(() => + teams.filter(team => team.selected).map(t => t.id).join(','), + [teams] + ); + + const selectedProjectIds = React.useMemo(() => + filterProjects.filter(project => project.selected).map(p => p.id).join(','), + [filterProjects] + ); + + const selectedCategoryIds = React.useMemo(() => + categories.filter(category => category.selected).map(c => c.id).join(','), + [categories] + ); + + const selectedMemberIds = React.useMemo(() => + members.filter(member => member.selected).map(m => m.id).join(','), + [members] + ); + + const selectedUtilizationIds = React.useMemo(() => + utilization.filter(item => item.selected).map(u => u.id).join(','), + [utilization] + ); + useEffect(() => { fetchChartData(); - }, [dispatch, duration, dateRange, billable, archived, teams, filterProjects, categories]); + }, [duration, dateRange, billable, archived, noCategory, selectedTeamIds, selectedProjectIds, selectedCategoryIds, selectedMemberIds, selectedUtilizationIds]); const exportChart = () => { if (chartRef.current) { @@ -197,7 +360,7 @@ const MembersTimeSheet = forwardRef((_, ref) => { }; useImperativeHandle(ref, () => ({ - exportChart, + exportChart })); return ( @@ -218,4 +381,4 @@ const MembersTimeSheet = forwardRef((_, ref) => { MembersTimeSheet.displayName = 'MembersTimeSheet'; -export default MembersTimeSheet; +export default MembersTimeSheet; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/reporting/timeReports/estimated-vs-actual-time-reports.tsx b/worklenz-frontend/src/pages/reporting/timeReports/estimated-vs-actual-time-reports.tsx index 8bcf649c..f599e21c 100644 --- a/worklenz-frontend/src/pages/reporting/timeReports/estimated-vs-actual-time-reports.tsx +++ b/worklenz-frontend/src/pages/reporting/timeReports/estimated-vs-actual-time-reports.tsx @@ -1,11 +1,11 @@ import { Card, Flex, Segmented } from '@/shared/antd-imports'; -import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header'; +import TimeReportPageHeader from '@/components/reporting/time-reports/page-header/TimeReportPageHeader'; import EstimatedVsActualTimeSheet, { EstimatedVsActualTimeSheetRef, } from '@/pages/reporting/time-reports/estimated-vs-actual-time-sheet/estimated-vs-actual-time-sheet'; import { useTranslation } from 'react-i18next'; import { useDocumentTitle } from '@/hooks/useDoumentTItle'; -import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader'; +import TimeReportingRightHeader from '@/components/reporting/time-reports/right-header/TimeReportingRightHeader'; import { useState, useRef } from 'react'; const EstimatedVsActualTimeReports = () => { diff --git a/worklenz-frontend/src/pages/reporting/timeReports/members-time-reports.tsx b/worklenz-frontend/src/pages/reporting/timeReports/members-time-reports.tsx index 351f46c2..4adfc249 100644 --- a/worklenz-frontend/src/pages/reporting/timeReports/members-time-reports.tsx +++ b/worklenz-frontend/src/pages/reporting/timeReports/members-time-reports.tsx @@ -1,9 +1,9 @@ import { Card, Flex } from '@/shared/antd-imports'; -import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header'; +import TimeReportPageHeader from '@/components/reporting/time-reports/page-header/TimeReportPageHeader'; import MembersTimeSheet, { MembersTimeSheetRef, } from '@/pages/reporting/time-reports/members-time-sheet/members-time-sheet'; -import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader'; +import TimeReportingRightHeader from '@/components/reporting/time-reports/right-header/TimeReportingRightHeader'; import { useTranslation } from 'react-i18next'; import { useDocumentTitle } from '@/hooks/useDoumentTItle'; import { useRef } from 'react'; @@ -20,10 +20,20 @@ const MembersTimeReports = () => { } }; + const handleTotalsUpdate = (totals: { + total_time_logs: string; + total_estimated_hours: string; + total_utilization: string; + }) => { + // Handle totals update if needed + // This could be used to display totals in the UI or pass to parent components + console.log('Totals updated:', totals); + }; + return ( @@ -43,7 +53,7 @@ const MembersTimeReports = () => { }, }} > - + ); diff --git a/worklenz-frontend/src/pages/reporting/timeReports/overview-time-reports.tsx b/worklenz-frontend/src/pages/reporting/timeReports/overview-time-reports.tsx index 7219ce5a..5ad0a1a1 100644 --- a/worklenz-frontend/src/pages/reporting/timeReports/overview-time-reports.tsx +++ b/worklenz-frontend/src/pages/reporting/timeReports/overview-time-reports.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header'; +import TimeReportPageHeader from '@/components/reporting/time-reports/page-header/TimeReportPageHeader'; import { Flex } from '@/shared/antd-imports'; import TimeSheetTable from '@/pages/reporting/time-reports/time-sheet-table/time-sheet-table'; -import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader'; +import TimeReportingRightHeader from '@/components/reporting/time-reports/right-header/TimeReportingRightHeader'; import { useTranslation } from 'react-i18next'; import { useDocumentTitle } from '@/hooks/useDoumentTItle'; import { useAppSelector } from '@/hooks/useAppSelector'; diff --git a/worklenz-frontend/src/pages/reporting/timeReports/page-header/billable.tsx b/worklenz-frontend/src/pages/reporting/timeReports/page-header/billable.tsx deleted file mode 100644 index 8fd29c77..00000000 --- a/worklenz-frontend/src/pages/reporting/timeReports/page-header/billable.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { setSelectOrDeselectBillable } from '@/features/reporting/time-reports/time-reports-overview.slice'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { CaretDownFilled } from '@/shared/antd-imports'; -import { Button, Checkbox, Dropdown, MenuProps } from '@/shared/antd-imports'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -const Billable: React.FC = () => { - const { t } = useTranslation('time-report'); - const dispatch = useAppDispatch(); - - const { billable } = useAppSelector(state => state.timeReportsOverviewReducer); - - // Dropdown items for the menu - const menuItems: MenuProps['items'] = [ - { - key: 'search', - label: {t('billable')}, - onClick: () => { - dispatch(setSelectOrDeselectBillable({ ...billable, billable: !billable.billable })); - }, - }, - { - key: 'selectAll', - label: {t('nonBillable')}, - onClick: () => { - dispatch(setSelectOrDeselectBillable({ ...billable, nonBillable: !billable.nonBillable })); - }, - }, - ]; - - return ( -
- - - -
- ); -}; - -export default Billable; diff --git a/worklenz-frontend/src/pages/reporting/timeReports/page-header/categories.tsx b/worklenz-frontend/src/pages/reporting/timeReports/page-header/categories.tsx deleted file mode 100644 index 064d65d5..00000000 --- a/worklenz-frontend/src/pages/reporting/timeReports/page-header/categories.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { - fetchReportingProjects, - setNoCategory, - setSelectOrDeselectAllCategories, - setSelectOrDeselectCategory, -} from '@/features/reporting/time-reports/time-reports-overview.slice'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { CaretDownFilled } from '@/shared/antd-imports'; -import { Button, Card, Checkbox, Divider, Dropdown, Input, theme } from '@/shared/antd-imports'; -import { CheckboxChangeEvent } from 'antd/es/checkbox'; -import React, { useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -const Categories: React.FC = () => { - const dispatch = useAppDispatch(); - - const [searchText, setSearchText] = useState(''); - const [selectAll, setSelectAll] = useState(true); - const { t } = useTranslation('time-report'); - const [dropdownVisible, setDropdownVisible] = useState(false); - const { categories, loadingCategories, noCategory } = useAppSelector( - state => state.timeReportsOverviewReducer - ); - const { token } = theme.useToken(); - - const filteredItems = categories.filter(item => - item.name?.toLowerCase().includes(searchText.toLowerCase()) - ); - - // Handle checkbox change for individual items - const handleCheckboxChange = async (key: string, checked: boolean) => { - await dispatch(setSelectOrDeselectCategory({ id: key, selected: checked })); - await dispatch(fetchReportingProjects()); - }; - - // Handle "Select All" checkbox change - const handleSelectAllChange = async (e: CheckboxChangeEvent) => { - const isChecked = e.target.checked; - setSelectAll(isChecked); - await dispatch(setNoCategory(isChecked)); - await dispatch(setSelectOrDeselectAllCategories(isChecked)); - await dispatch(fetchReportingProjects()); - }; - - const handleNoCategoryChange = async (checked: boolean) => { - await dispatch(setNoCategory(checked)); - await dispatch(fetchReportingProjects()); - }; - - return ( -
- ( -
-
- e.stopPropagation()} - placeholder={t('searchByCategory')} - value={searchText} - onChange={e => setSearchText(e.target.value)} - /> -
- {categories.length > 0 && ( -
- e.stopPropagation()} - onChange={handleSelectAllChange} - checked={selectAll} - > - {t('selectAll')} - -
- )} -
- e.stopPropagation()} - checked={noCategory} - onChange={e => handleNoCategoryChange(e.target.checked)} - > - {t('noCategory')} - -
- -
- {filteredItems.length > 0 ? ( - filteredItems.map(item => ( -
- e.stopPropagation()} - checked={item.selected} - onChange={e => handleCheckboxChange(item.id || '', e.target.checked)} - > - {item.name} - -
- )) - ) : ( -
{t('noCategories')}
- )} -
-
- )} - onOpenChange={visible => { - setDropdownVisible(visible); - if (!visible) { - setSearchText(''); - } - }} - > - -
-
- ); -}; - -export default Categories; diff --git a/worklenz-frontend/src/pages/reporting/timeReports/page-header/projects.tsx b/worklenz-frontend/src/pages/reporting/timeReports/page-header/projects.tsx deleted file mode 100644 index 823461b3..00000000 --- a/worklenz-frontend/src/pages/reporting/timeReports/page-header/projects.tsx +++ /dev/null @@ -1,680 +0,0 @@ -import { - setSelectOrDeselectAllProjects, - setSelectOrDeselectProject, -} from '@/features/reporting/time-reports/time-reports-overview.slice'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { - CaretDownFilled, - SearchOutlined, - ClearOutlined, - DownOutlined, - RightOutlined, - FilterOutlined, -} from '@/shared/antd-imports'; -import { - Button, - Checkbox, - Divider, - Dropdown, - Input, - theme, - Typography, - Badge, - Collapse, - Select, - Space, - Tooltip, - Empty, -} from '@/shared/antd-imports'; -import { CheckboxChangeEvent } from 'antd/es/checkbox'; -import React, { useState, useMemo, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ISelectableProject } from '@/types/reporting/reporting-filters.types'; -import { themeWiseColor } from '@/utils/themeWiseColor'; - -const { Panel } = Collapse; -const { Text } = Typography; - -type GroupByOption = 'none' | 'category' | 'team' | 'status'; - -interface ProjectGroup { - key: string; - name: string; - color?: string; - projects: ISelectableProject[]; -} - -const Projects: React.FC = () => { - const dispatch = useAppDispatch(); - const [searchText, setSearchText] = useState(''); - const [groupBy, setGroupBy] = useState('none'); - const [showSelectedOnly, setShowSelectedOnly] = useState(false); - const [expandedGroups, setExpandedGroups] = useState([]); - const { t } = useTranslation('time-report'); - const [dropdownVisible, setDropdownVisible] = useState(false); - const { projects, loadingProjects } = useAppSelector(state => state.timeReportsOverviewReducer); - const { token } = theme.useToken(); - const themeMode = useAppSelector(state => state.themeReducer.mode); - - // Theme-aware color utilities - const getThemeAwareColor = useCallback( - (lightColor: string, darkColor: string) => { - return themeWiseColor(lightColor, darkColor, themeMode); - }, - [themeMode] - ); - - // Enhanced color processing for project/group colors - const processColor = useCallback( - (color: string | undefined, fallback?: string) => { - if (!color) return fallback || token.colorPrimary; - - // If it's a hex color, ensure it has good contrast in both themes - if (color.startsWith('#')) { - // For dark mode, lighten dark colors and darken light colors for better visibility - if (themeMode === 'dark') { - // Simple brightness adjustment for dark mode - const hex = color.replace('#', ''); - const r = parseInt(hex.substr(0, 2), 16); - const g = parseInt(hex.substr(2, 2), 16); - const b = parseInt(hex.substr(4, 2), 16); - - // Calculate brightness (0-255) - const brightness = (r * 299 + g * 587 + b * 114) / 1000; - - // If color is too dark in dark mode, lighten it - if (brightness < 100) { - const factor = 1.5; - const newR = Math.min(255, Math.floor(r * factor)); - const newG = Math.min(255, Math.floor(g * factor)); - const newB = Math.min(255, Math.floor(b * factor)); - return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`; - } - } else { - // For light mode, ensure colors aren't too light - const hex = color.replace('#', ''); - const r = parseInt(hex.substr(0, 2), 16); - const g = parseInt(hex.substr(2, 2), 16); - const b = parseInt(hex.substr(4, 2), 16); - - const brightness = (r * 299 + g * 587 + b * 114) / 1000; - - // If color is too light in light mode, darken it - if (brightness > 200) { - const factor = 0.7; - const newR = Math.floor(r * factor); - const newG = Math.floor(g * factor); - const newB = Math.floor(b * factor); - return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`; - } - } - } - - return color; - }, - [themeMode, token.colorPrimary] - ); - - // Memoized filtered projects - const filteredProjects = useMemo(() => { - let filtered = projects.filter(item => - item.name?.toLowerCase().includes(searchText.toLowerCase()) - ); - - if (showSelectedOnly) { - filtered = filtered.filter(item => item.selected); - } - - return filtered; - }, [projects, searchText, showSelectedOnly]); - - // Memoized grouped projects - const groupedProjects = useMemo(() => { - if (groupBy === 'none') { - return [ - { - key: 'all', - name: t('projects'), - projects: filteredProjects, - }, - ]; - } - - const groups: { [key: string]: ProjectGroup } = {}; - - filteredProjects.forEach(project => { - let groupKey: string; - let groupName: string; - let groupColor: string | undefined; - - switch (groupBy) { - case 'category': - groupKey = (project as any).category_id || 'uncategorized'; - groupName = (project as any).category_name || t('noCategory'); - groupColor = (project as any).category_color; - break; - case 'team': - groupKey = (project as any).team_id || 'no-team'; - groupName = (project as any).team_name || t('ungrouped'); - groupColor = (project as any).team_color; - break; - case 'status': - groupKey = (project as any).status_id || 'no-status'; - groupName = (project as any).status_name || t('ungrouped'); - groupColor = (project as any).status_color; - break; - default: - groupKey = 'all'; - groupName = t('projects'); - } - - if (!groups[groupKey]) { - groups[groupKey] = { - key: groupKey, - name: groupName, - color: processColor(groupColor), - projects: [], - }; - } - - groups[groupKey].projects.push(project); - }); - - return Object.values(groups).sort((a, b) => a.name.localeCompare(b.name)); - }, [filteredProjects, groupBy, t, processColor]); - - // Selected projects count - const selectedCount = useMemo(() => projects.filter(p => p.selected).length, [projects]); - - const allSelected = useMemo( - () => filteredProjects.length > 0 && filteredProjects.every(p => p.selected), - [filteredProjects] - ); - - const indeterminate = useMemo( - () => filteredProjects.some(p => p.selected) && !allSelected, - [filteredProjects, allSelected] - ); - - // Memoize group by options - const groupByOptions = useMemo( - () => [ - { value: 'none', label: t('groupByNone') }, - { value: 'category', label: t('groupByCategory') }, - { value: 'team', label: t('groupByTeam') }, - { value: 'status', label: t('groupByStatus') }, - ], - [t] - ); - - // Memoize dropdown styles to prevent recalculation on every render - const dropdownStyles = useMemo( - () => ({ - dropdown: { - background: token.colorBgContainer, - borderRadius: token.borderRadius, - boxShadow: token.boxShadowSecondary, - border: `1px solid ${token.colorBorder}`, - }, - groupHeader: { - backgroundColor: getThemeAwareColor(token.colorFillTertiary, token.colorFillQuaternary), - borderRadius: token.borderRadiusSM, - padding: '8px 12px', - marginBottom: '4px', - cursor: 'pointer', - transition: 'all 0.2s ease', - border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`, - }, - projectItem: { - padding: '8px 12px', - borderRadius: token.borderRadiusSM, - transition: 'all 0.2s ease', - cursor: 'pointer', - border: `1px solid transparent`, - }, - toggleIcon: { - color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary), - fontSize: '12px', - transition: 'all 0.2s ease', - }, - expandedToggleIcon: { - color: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive), - fontSize: '12px', - transition: 'all 0.2s ease', - }, - }), - [token, getThemeAwareColor] - ); - - // Memoize search placeholder and clear tooltip - const searchPlaceholder = useMemo(() => t('searchByProject'), [t]); - const clearTooltip = useMemo(() => t('clearSearch'), [t]); - const showSelectedTooltip = useMemo(() => t('showSelected'), [t]); - const selectAllText = useMemo(() => t('selectAll'), [t]); - const projectsSelectedText = useMemo(() => t('projectsSelected'), [t]); - const noProjectsText = useMemo(() => t('noProjects'), [t]); - const noDataText = useMemo(() => t('noData'), [t]); - const expandAllText = useMemo(() => t('expandAll'), [t]); - const collapseAllText = useMemo(() => t('collapseAll'), [t]); - - // Handle checkbox change for individual items - const handleCheckboxChange = useCallback( - (key: string, checked: boolean) => { - dispatch(setSelectOrDeselectProject({ id: key, selected: checked })); - }, - [dispatch] - ); - - // Handle "Select All" checkbox change - const handleSelectAllChange = useCallback( - (e: CheckboxChangeEvent) => { - const isChecked = e.target.checked; - dispatch(setSelectOrDeselectAllProjects(isChecked)); - }, - [dispatch] - ); - - // Clear search - const clearSearch = useCallback(() => { - setSearchText(''); - }, []); - - // Toggle group expansion - const toggleGroupExpansion = useCallback((groupKey: string) => { - setExpandedGroups(prev => - prev.includes(groupKey) ? prev.filter(key => key !== groupKey) : [...prev, groupKey] - ); - }, []); - - // Expand/Collapse all groups - const toggleAllGroups = useCallback( - (expand: boolean) => { - if (expand) { - setExpandedGroups(groupedProjects.map(g => g.key)); - } else { - setExpandedGroups([]); - } - }, - [groupedProjects] - ); - - // Render project group - const renderProjectGroup = (group: ProjectGroup) => { - const isExpanded = expandedGroups.includes(group.key) || groupBy === 'none'; - const groupSelectedCount = group.projects.filter(p => p.selected).length; - - return ( -
- {groupBy !== 'none' && ( -
toggleGroupExpansion(group.key)} - onMouseEnter={e => { - e.currentTarget.style.backgroundColor = getThemeAwareColor( - token.colorFillSecondary, - token.colorFillTertiary - ); - e.currentTarget.style.borderColor = getThemeAwareColor( - token.colorBorder, - token.colorBorderSecondary - ); - }} - onMouseLeave={e => { - e.currentTarget.style.backgroundColor = isExpanded - ? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary) - : dropdownStyles.groupHeader.backgroundColor; - e.currentTarget.style.borderColor = getThemeAwareColor( - token.colorBorderSecondary, - token.colorBorder - ); - }} - > - - {isExpanded ? ( - - ) : ( - - )} -
- - {group.name} - - - -
- )} - - {isExpanded && ( -
- {group.projects.map(project => ( -
{ - e.currentTarget.style.backgroundColor = getThemeAwareColor( - token.colorFillAlter, - token.colorFillQuaternary - ); - e.currentTarget.style.borderColor = getThemeAwareColor( - token.colorBorderSecondary, - token.colorBorder - ); - }} - onMouseLeave={e => { - e.currentTarget.style.backgroundColor = 'transparent'; - e.currentTarget.style.borderColor = 'transparent'; - }} - > - e.stopPropagation()} - checked={project.selected} - onChange={e => handleCheckboxChange(project.id || '', e.target.checked)} - > - -
- - {project.name} - - - -
- ))} -
- )} -
- ); - }; - - return ( -
- ( -
- {/* Header with search and controls */} -
- - {/* Search input */} - setSearchText(e.target.value)} - prefix={ - - } - suffix={ - searchText && ( - - { - e.currentTarget.style.color = getThemeAwareColor( - token.colorTextSecondary, - token.colorTextTertiary - ); - }} - onMouseLeave={e => { - e.currentTarget.style.color = getThemeAwareColor( - token.colorTextTertiary, - token.colorTextQuaternary - ); - }} - /> - - ) - } - onClick={e => e.stopPropagation()} - /> - - {/* Controls row */} - - - setSearchText(e.target.value)} - onClick={e => e.stopPropagation()} - /> -
-
- e.stopPropagation()} - onChange={handleSelectAllChange} - checked={selectAll} - > - {t('selectAll')} - -
- -
- {filteredItems.map(item => ( -
- e.stopPropagation()} - checked={item.selected} - onChange={e => handleCheckboxChange(item.id || '', e.target.checked)} - > - {item.name} - -
- ))} -
-
- )} - onOpenChange={visible => { - setDropdownVisible(visible); - if (!visible) { - setSearchText(''); - } - }} - > - -
-
- ); -}; - -export default Team; diff --git a/worklenz-frontend/src/pages/reporting/timeReports/page-header/time-report-page-header.tsx b/worklenz-frontend/src/pages/reporting/timeReports/page-header/time-report-page-header.tsx deleted file mode 100644 index 20c3e152..00000000 --- a/worklenz-frontend/src/pages/reporting/timeReports/page-header/time-report-page-header.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { useEffect } from 'react'; -import Team from './team'; -import Categories from './categories'; -import Projects from './projects'; -import Billable from './billable'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { - fetchReportingTeams, - fetchReportingProjects, - fetchReportingCategories, -} from '@/features/reporting/time-reports/time-reports-overview.slice'; - -const TimeReportPageHeader: React.FC = () => { - const dispatch = useAppDispatch(); - - useEffect(() => { - const fetchData = async () => { - await dispatch(fetchReportingTeams()); - await dispatch(fetchReportingCategories()); - await dispatch(fetchReportingProjects()); - }; - - fetchData(); - }, [dispatch]); - - return ( -
- - - - -
- ); -}; - -export default TimeReportPageHeader; diff --git a/worklenz-frontend/src/pages/reporting/timeReports/projects-time-reports.tsx b/worklenz-frontend/src/pages/reporting/timeReports/projects-time-reports.tsx index 45b829b3..668b269f 100644 --- a/worklenz-frontend/src/pages/reporting/timeReports/projects-time-reports.tsx +++ b/worklenz-frontend/src/pages/reporting/timeReports/projects-time-reports.tsx @@ -1,11 +1,11 @@ import { Card, Flex } from '@/shared/antd-imports'; -import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header'; +import TimeReportPageHeader from '@/components/reporting/time-reports/page-header/TimeReportPageHeader'; import ProjectTimeSheetChart, { ProjectTimeSheetChartRef, } from '@/pages/reporting/time-reports/project-time-sheet/project-time-sheet-chart'; import { useTranslation } from 'react-i18next'; import { useDocumentTitle } from '@/hooks/useDoumentTItle'; -import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader'; +import TimeReportingRightHeader from '@/components/reporting/time-reports/right-header/TimeReportingRightHeader'; import { useRef } from 'react'; const ProjectsTimeReports = () => { diff --git a/worklenz-frontend/src/pages/settings/language-and-region/language-and-region-settings.tsx b/worklenz-frontend/src/pages/settings/language-and-region/language-and-region-settings.tsx index 9e860b46..0a4e1e23 100644 --- a/worklenz-frontend/src/pages/settings/language-and-region/language-and-region-settings.tsx +++ b/worklenz-frontend/src/pages/settings/language-and-region/language-and-region-settings.tsx @@ -56,7 +56,7 @@ const LanguageAndRegionSettings = () => { label: 'Deutsch', }, { - value: Language.ZH_CN, + value: Language.ZH, label: '简体中文', }, ]; diff --git a/worklenz-frontend/src/shared/antd-imports.ts b/worklenz-frontend/src/shared/antd-imports.ts index 9f532aa2..1dfd149b 100644 --- a/worklenz-frontend/src/shared/antd-imports.ts +++ b/worklenz-frontend/src/shared/antd-imports.ts @@ -263,6 +263,7 @@ export type { PaginationProps, CollapseProps, TablePaginationConfig, + CheckboxChangeEvent } from 'antd/es'; // Dayjs diff --git a/worklenz-frontend/src/utils/current-date-string.ts b/worklenz-frontend/src/utils/current-date-string.ts index 10b87929..f040dfd7 100644 --- a/worklenz-frontend/src/utils/current-date-string.ts +++ b/worklenz-frontend/src/utils/current-date-string.ts @@ -20,7 +20,7 @@ export const currentDateString = (): string => { case 'de': locale = 'de'; break; - case 'zh_cn': + case 'zh': locale = 'zh-cn'; break; case 'alb': @@ -45,7 +45,7 @@ export const currentDateString = (): string => { case 'de': todayText = 'Heute ist'; break; - case 'zh_cn': + case 'zh': todayText = '今天是'; break; case 'alb': diff --git a/worklenz-frontend/src/utils/greetingString.ts b/worklenz-frontend/src/utils/greetingString.ts index 86043f0b..9d07d18a 100644 --- a/worklenz-frontend/src/utils/greetingString.ts +++ b/worklenz-frontend/src/utils/greetingString.ts @@ -41,7 +41,7 @@ export const greetingString = (name: string): string => { morning = 'Morgen'; afternoon = 'Tag'; evening = 'Abend'; - } else if (language === 'zh_cn') { + } else if (language === 'zh') { greetingPrefix = '你好'; greetingSuffix = ''; morning = '早上好'; @@ -56,7 +56,7 @@ export const greetingString = (name: string): string => { else localizedTimePeriod = evening; // Handle Chinese language which has different structure - if (language === 'zh_cn') { + if (language === 'zh') { return `${greetingPrefix} ${name}, ${localizedTimePeriod}!`; }