diff --git a/worklenz-backend/database/pg-migrations/README.md b/worklenz-backend/database/pg-migrations/README.md new file mode 100644 index 00000000..ee063447 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/README.md @@ -0,0 +1,72 @@ +# Node-pg-migrate Migrations + +This directory contains database migrations managed by node-pg-migrate. + +## Migration Commands + +- `npm run migrate:create -- migration-name` - Create a new migration file +- `npm run migrate:up` - Run all pending migrations +- `npm run migrate:down` - Rollback the last migration +- `npm run migrate:redo` - Rollback and re-run the last migration + +## Migration File Format + +Migrations are JavaScript files with timestamp prefixes (e.g., `20250115000000_performance-indexes.js`). + +Each migration file exports two functions: +- `exports.up` - Contains the forward migration logic +- `exports.down` - Contains the rollback logic + +## Best Practices + +1. **Always use IF EXISTS/IF NOT EXISTS checks** to make migrations idempotent +2. **Test migrations locally** before deploying to production +3. **Include rollback logic** in the `down` function for all changes +4. **Use descriptive names** for migration files +5. **Keep migrations focused** - one logical change per migration + +## Example Migration + +```javascript +exports.up = pgm => { + // Create table with IF NOT EXISTS + pgm.createTable('users', { + id: 'id', + name: { type: 'varchar(100)', notNull: true }, + created_at: { + type: 'timestamp', + notNull: true, + default: pgm.func('current_timestamp') + } + }, { ifNotExists: true }); + + // Add index with IF NOT EXISTS + pgm.createIndex('users', 'name', { + name: 'idx_users_name', + ifNotExists: true + }); +}; + +exports.down = pgm => { + // Drop in reverse order + pgm.dropIndex('users', 'name', { + name: 'idx_users_name', + ifExists: true + }); + + pgm.dropTable('users', { ifExists: true }); +}; +``` + +## Migration History + +The `pgmigrations` table tracks which migrations have been run. Do not modify this table manually. + +## Converting from SQL Migrations + +When converting SQL migrations to node-pg-migrate format: + +1. Wrap SQL statements in `pgm.sql()` calls +2. Use node-pg-migrate helper methods where possible (createTable, addColumns, etc.) +3. Always include `IF EXISTS/IF NOT EXISTS` checks +4. Ensure proper rollback logic in the `down` function \ No newline at end of file diff --git a/worklenz-backend/src/controllers/tasks-controller-base.ts b/worklenz-backend/src/controllers/tasks-controller-base.ts index 58558c1e..43a4909c 100644 --- a/worklenz-backend/src/controllers/tasks-controller-base.ts +++ b/worklenz-backend/src/controllers/tasks-controller-base.ts @@ -16,6 +16,7 @@ export interface ITaskGroup { start_date?: string; end_date?: string; color_code: string; + color_code_dark: string; category_id: string | null; old_category_id?: string; todo_progress?: number; diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index d38a563d..daa29ee5 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -5,9 +5,16 @@ import HandleExceptions from "../decorators/handle-exceptions"; import { IWorkLenzRequest } from "../interfaces/worklenz-request"; import { IWorkLenzResponse } from "../interfaces/worklenz-response"; import { ServerResponse } from "../models/server-response"; -import { TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../shared/constants"; +import { + TASK_PRIORITY_COLOR_ALPHA, + TASK_STATUS_COLOR_ALPHA, + UNMAPPED, +} from "../shared/constants"; import { getColor, log_error } from "../shared/utils"; -import TasksControllerBase, { GroupBy, ITaskGroup } from "./tasks-controller-base"; +import TasksControllerBase, { + GroupBy, + ITaskGroup, +} from "./tasks-controller-base"; export class TaskListGroup implements ITaskGroup { name: string; @@ -45,7 +52,10 @@ export default class TasksControllerV2 extends TasksControllerBase { } private static flatString(text: string) { - return (text || "").split(" ").map(s => `'${s}'`).join(","); + return (text || "") + .split(" ") + .map((s) => `'${s}'`) + .join(","); } private static getFilterByStatusWhereClosure(text: string) { @@ -58,13 +68,17 @@ export default class TasksControllerV2 extends TasksControllerBase { private static getFilterByLabelsWhereClosure(text: string) { return text - ? `id IN (SELECT task_id FROM task_labels WHERE label_id IN (${this.flatString(text)}))` + ? `id IN (SELECT task_id FROM task_labels WHERE label_id IN (${this.flatString( + text + )}))` : ""; } private static getFilterByMembersWhereClosure(text: string) { return text - ? `id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id IN (${this.flatString(text)}))` + ? `id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id IN (${this.flatString( + text + )}))` : ""; } @@ -95,10 +109,13 @@ export default class TasksControllerV2 extends TasksControllerBase { total_tasks: number; } | null> { try { - const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]); + const result = await db.query( + "SELECT get_task_complete_ratio($1) AS info;", + [taskId] + ); const [data] = result.rows; if (data && data.info && data.info.ratio !== undefined) { - data.info.ratio = +((data.info.ratio || 0).toFixed()); + data.info.ratio = +(data.info.ratio || 0).toFixed(); return data.info; } return null; @@ -110,43 +127,67 @@ export default class TasksControllerV2 extends TasksControllerBase { private static getQuery(userId: string, options: ParsedQs) { // Determine which sort column to use based on grouping - const groupBy = options.group || 'status'; - let defaultSortColumn = 'sort_order'; + const groupBy = options.group || "status"; + let defaultSortColumn = "sort_order"; switch (groupBy) { - case 'status': - defaultSortColumn = 'status_sort_order'; + case "status": + defaultSortColumn = "status_sort_order"; break; - case 'priority': - defaultSortColumn = 'priority_sort_order'; + case "priority": + defaultSortColumn = "priority_sort_order"; break; - case 'phase': - defaultSortColumn = 'phase_sort_order'; + case "phase": + defaultSortColumn = "phase_sort_order"; break; default: - defaultSortColumn = 'sort_order'; + defaultSortColumn = "sort_order"; } - const searchField = options.search ? ["t.name", "CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no)"] : defaultSortColumn; - const { searchQuery, sortField } = TasksControllerV2.toPaginationOptions(options, searchField); + const searchField = options.search + ? [ + "t.name", + "CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no)", + ] + : defaultSortColumn; + const { searchQuery, sortField } = TasksControllerV2.toPaginationOptions( + options, + searchField + ); const isSubTasks = !!options.parent_task; - const sortFields = sortField.replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || defaultSortColumn; + const sortFields = + sortField.replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || + defaultSortColumn; // Filter tasks by statuses - const statusesFilter = TasksControllerV2.getFilterByStatusWhereClosure(options.statuses as string); + const statusesFilter = TasksControllerV2.getFilterByStatusWhereClosure( + options.statuses as string + ); // Filter tasks by labels - const labelsFilter = TasksControllerV2.getFilterByLabelsWhereClosure(options.labels as string); + const labelsFilter = TasksControllerV2.getFilterByLabelsWhereClosure( + options.labels as string + ); // Filter tasks by its members - const membersFilter = TasksControllerV2.getFilterByMembersWhereClosure(options.members as string); + const membersFilter = TasksControllerV2.getFilterByMembersWhereClosure( + options.members as string + ); // Filter tasks by projects - const projectsFilter = TasksControllerV2.getFilterByProjectsWhereClosure(options.projects as string); + const projectsFilter = TasksControllerV2.getFilterByProjectsWhereClosure( + options.projects as string + ); // Filter tasks by priorities - const priorityFilter = TasksControllerV2.getFilterByPriorityWhereClosure(options.priorities as string); + const priorityFilter = TasksControllerV2.getFilterByPriorityWhereClosure( + options.priorities as string + ); // Filter tasks by a single assignee - const filterByAssignee = TasksControllerV2.getFilterByAssignee(options.filterBy as string); + const filterByAssignee = TasksControllerV2.getFilterByAssignee( + options.filterBy as string + ); // Returns statuses of each task as a json array if filterBy === "member" - const statusesQuery = TasksControllerV2.getStatusesQuery(options.filterBy as string); + const statusesQuery = TasksControllerV2.getStatusesQuery( + options.filterBy as string + ); // Custom columns data query const customColumnsQuery = options.customColumns @@ -175,26 +216,31 @@ export default class TasksControllerV2 extends TasksControllerBase { WHERE custom_cols.value IS NOT NULL) AS custom_column_values` : ""; - const archivedFilter = options.archived === "true" ? "archived IS TRUE" : "archived IS FALSE"; + const archivedFilter = + options.archived === "true" ? "archived IS TRUE" : "archived IS FALSE"; let subTasksFilter; if (options.isSubtasksInclude === "true") { subTasksFilter = ""; } else { - subTasksFilter = isSubTasks ? "parent_task_id = $2" : "parent_task_id IS NULL"; + subTasksFilter = isSubTasks + ? "parent_task_id = $2" + : "parent_task_id IS NULL"; } const filters = [ subTasksFilter, - (isSubTasks ? "1 = 1" : archivedFilter), - (isSubTasks ? "$1 = $1" : filterByAssignee), // ignored filter by member in peoples page for sub-tasks + isSubTasks ? "1 = 1" : archivedFilter, + isSubTasks ? "$1 = $1" : filterByAssignee, // ignored filter by member in peoples page for sub-tasks statusesFilter, priorityFilter, labelsFilter, membersFilter, - projectsFilter - ].filter(i => !!i).join(" AND "); + projectsFilter, + ] + .filter((i) => !!i) + .join(" AND "); return ` SELECT id, @@ -292,7 +338,10 @@ export default class TasksControllerV2 extends TasksControllerBase { `; } - public static async getGroups(groupBy: string, projectId: string): Promise { + public static async getGroups( + groupBy: string, + projectId: string + ): Promise { let q = ""; let params: any[] = []; switch (groupBy) { @@ -345,19 +394,30 @@ export default class TasksControllerV2 extends TasksControllerBase { } @HandleExceptions() - public static async getList(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async getList( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { const startTime = performance.now(); - console.log(`[PERFORMANCE] getList method called for project ${req.params.id} - THIS METHOD IS DEPRECATED, USE getTasksV3 INSTEAD`); + console.log( + `[PERFORMANCE] getList method called for project ${req.params.id} - THIS METHOD IS DEPRECATED, USE getTasksV3 INSTEAD` + ); // PERFORMANCE OPTIMIZATION: Skip expensive progress calculation by default // Progress values are already calculated and stored in the database // Only refresh if explicitly requested via refresh_progress=true query parameter if (req.query.refresh_progress === "true" && req.params.id) { - console.log(`[PERFORMANCE] Starting progress refresh for project ${req.params.id} (getList)`); + console.log( + `[PERFORMANCE] Starting progress refresh for project ${req.params.id} (getList)` + ); const progressStartTime = performance.now(); await this.refreshProjectTaskProgressValues(req.params.id); const progressEndTime = performance.now(); - console.log(`[PERFORMANCE] Progress refresh completed in ${(progressEndTime - progressStartTime).toFixed(2)}ms`); + console.log( + `[PERFORMANCE] Progress refresh completed in ${( + progressEndTime - progressStartTime + ).toFixed(2)}ms` + ); } const isSubTasks = !!req.query.parent_task; @@ -367,21 +427,22 @@ export default class TasksControllerV2 extends TasksControllerBase { req.query.customColumns = "true"; const q = TasksControllerV2.getQuery(req.user?.id as string, req.query); - const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; + const params = isSubTasks + ? [req.params.id || null, req.query.parent_task] + : [req.params.id || null]; const result = await db.query(q, params); const tasks = [...result.rows]; const groups = await this.getGroups(groupBy, req.params.id); const map = groups.reduce((g: { [x: string]: ITaskGroup }, group) => { - if (group.id) - g[group.id] = new TaskListGroup(group); + if (group.id) g[group.id] = new TaskListGroup(group); return g; }, {}); await this.updateMapByGroup(tasks, groupBy, map); - const updatedGroups = Object.keys(map).map(key => { + const updatedGroups = Object.keys(map).map((key) => { const group = map[key]; TasksControllerV2.updateTaskProgresses(group); @@ -391,23 +452,35 @@ export default class TasksControllerV2 extends TasksControllerBase { return { id: key, - ...group + ...group, }; }); const endTime = performance.now(); const totalTime = endTime - startTime; - console.log(`[PERFORMANCE] getList method completed in ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${tasks.length} tasks`); + console.log( + `[PERFORMANCE] getList method completed in ${totalTime.toFixed( + 2 + )}ms for project ${req.params.id} with ${tasks.length} tasks` + ); // Log warning if this deprecated method is taking too long if (totalTime > 1000) { - console.warn(`[PERFORMANCE WARNING] DEPRECATED getList method taking ${totalTime.toFixed(2)}ms - Frontend should use getTasksV3 instead!`); + console.warn( + `[PERFORMANCE WARNING] DEPRECATED getList method taking ${totalTime.toFixed( + 2 + )}ms - Frontend should use getTasksV3 instead!` + ); } return res.status(200).send(new ServerResponse(true, updatedGroups)); } - public static async updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: ITaskGroup }) { + public static async updateMapByGroup( + tasks: any[], + groupBy: string, + map: { [p: string]: ITaskGroup } + ) { let index = 0; const unmapped = []; @@ -436,15 +509,22 @@ export default class TasksControllerV2 extends TasksControllerBase { name: UNMAPPED, category_id: null, color_code: "#fbc84c69", - tasks: unmapped + color_code_dark: "#fbc84c69", + tasks: unmapped, }; } } public static updateTaskProgresses(group: ITaskGroup) { - const todoCount = group.tasks.filter(t => t.status_category?.is_todo).length; - const doingCount = group.tasks.filter(t => t.status_category?.is_doing).length; - const doneCount = group.tasks.filter(t => t.status_category?.is_done).length; + const todoCount = group.tasks.filter( + (t) => t.status_category?.is_todo + ).length; + const doingCount = group.tasks.filter( + (t) => t.status_category?.is_doing + ).length; + const doneCount = group.tasks.filter( + (t) => t.status_category?.is_done + ).length; const total = group.tasks.length; @@ -454,19 +534,30 @@ export default class TasksControllerV2 extends TasksControllerBase { } @HandleExceptions() - public static async getTasksOnly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async getTasksOnly( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { const startTime = performance.now(); - console.log(`[PERFORMANCE] getTasksOnly method called for project ${req.params.id} - Consider using getTasksV3 for better performance`); + console.log( + `[PERFORMANCE] getTasksOnly method called for project ${req.params.id} - Consider using getTasksV3 for better performance` + ); // PERFORMANCE OPTIMIZATION: Skip expensive progress calculation by default // Progress values are already calculated and stored in the database // Only refresh if explicitly requested via refresh_progress=true query parameter if (req.query.refresh_progress === "true" && req.params.id) { - console.log(`[PERFORMANCE] Starting progress refresh for project ${req.params.id} (getTasksOnly)`); + console.log( + `[PERFORMANCE] Starting progress refresh for project ${req.params.id} (getTasksOnly)` + ); const progressStartTime = performance.now(); await this.refreshProjectTaskProgressValues(req.params.id); const progressEndTime = performance.now(); - console.log(`[PERFORMANCE] Progress refresh completed in ${(progressEndTime - progressStartTime).toFixed(2)}ms`); + console.log( + `[PERFORMANCE] Progress refresh completed in ${( + progressEndTime - progressStartTime + ).toFixed(2)}ms` + ); } const isSubTasks = !!req.query.parent_task; @@ -475,7 +566,9 @@ export default class TasksControllerV2 extends TasksControllerBase { req.query.customColumns = "true"; const q = TasksControllerV2.getQuery(req.user?.id as string, req.query); - const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; + const params = isSubTasks + ? [req.params.id || null, req.query.parent_task] + : [req.params.id || null]; const result = await db.query(q, params); let data: any[] = []; @@ -483,7 +576,8 @@ export default class TasksControllerV2 extends TasksControllerBase { // if true, we only return the record count if (this.isCountsOnly(req.query)) { [data] = result.rows; - } else { // else we return a flat list of tasks + } else { + // else we return a flat list of tasks data = [...result.rows]; // PERFORMANCE OPTIMIZATION: Remove expensive individual DB calls for each task @@ -497,18 +591,29 @@ export default class TasksControllerV2 extends TasksControllerBase { const endTime = performance.now(); const totalTime = endTime - startTime; - console.log(`[PERFORMANCE] getTasksOnly method completed in ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${data.length} tasks`); + console.log( + `[PERFORMANCE] getTasksOnly method completed in ${totalTime.toFixed( + 2 + )}ms for project ${req.params.id} with ${data.length} tasks` + ); // Log warning if this method is taking too long if (totalTime > 1000) { - console.warn(`[PERFORMANCE WARNING] getTasksOnly method taking ${totalTime.toFixed(2)}ms - Consider using getTasksV3 for better performance!`); + console.warn( + `[PERFORMANCE WARNING] getTasksOnly method taking ${totalTime.toFixed( + 2 + )}ms - Consider using getTasksV3 for better performance!` + ); } return res.status(200).send(new ServerResponse(true, data)); } @HandleExceptions() - public static async convertToTask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async convertToTask( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { const q = ` UPDATE tasks SET parent_task_id = NULL, @@ -517,14 +622,19 @@ export default class TasksControllerV2 extends TasksControllerBase { `; await db.query(q, [req.body.id, req.body.project_id]); - const result = await db.query("SELECT get_single_task($1) AS task;", [req.body.id]); + const result = await db.query("SELECT get_single_task($1) AS task;", [ + req.body.id, + ]); const [data] = result.rows; const model = TasksControllerV2.updateTaskViewModel(data.task); return res.status(200).send(new ServerResponse(true, model)); } @HandleExceptions() - public static async getNewKanbanTask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async getNewKanbanTask( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { const { id } = req.params; const result = await db.query("SELECT get_single_task($1) AS task;", [id]); const [data] = result.rows; @@ -533,7 +643,9 @@ export default class TasksControllerV2 extends TasksControllerBase { } @HandleExceptions() - public static async resetParentTaskManualProgress(parentTaskId: string): Promise { + public static async resetParentTaskManualProgress( + parentTaskId: string + ): Promise { try { // Check if this task has subtasks const subTasksResult = await db.query( @@ -541,7 +653,9 @@ export default class TasksControllerV2 extends TasksControllerBase { [parentTaskId] ); - const subtaskCount = parseInt(subTasksResult.rows[0]?.subtask_count || "0"); + const subtaskCount = parseInt( + subTasksResult.rows[0]?.subtask_count || "0" + ); // If it has subtasks, reset the manual_progress flag to false if (subtaskCount > 0) { @@ -549,7 +663,9 @@ export default class TasksControllerV2 extends TasksControllerBase { "UPDATE tasks SET manual_progress = false WHERE id = $1", [parentTaskId] ); - console.log(`Reset manual progress for parent task ${parentTaskId} with ${subtaskCount} subtasks`); + console.log( + `Reset manual progress for parent task ${parentTaskId} with ${subtaskCount} subtasks` + ); // Get the project settings to determine which calculation method to use const projectResult = await db.query( @@ -571,7 +687,9 @@ export default class TasksControllerV2 extends TasksControllerBase { // Emit the updated progress value to all clients // Note: We don't have socket context here, so we can't directly emit // This will be picked up on the next client refresh - console.log(`Recalculated progress for parent task ${parentTaskId}: ${progressRatio}%`); + console.log( + `Recalculated progress for parent task ${parentTaskId}: ${progressRatio}%` + ); } } } catch (error) { @@ -580,8 +698,10 @@ export default class TasksControllerV2 extends TasksControllerBase { } @HandleExceptions() - public static async convertToSubtask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - + public static async convertToSubtask( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { const groupType = req.body.group_by; let q = ``; @@ -602,21 +722,29 @@ export default class TasksControllerV2 extends TasksControllerBase { WHERE id = $1; `; } else if (groupType === "phase") { - await db.query(` + await db.query( + ` UPDATE tasks SET parent_task_id = $3, sort_order = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = $2), 0) WHERE id = $1; - `, [req.body.id, req.body.project_id, req.body.parent_task_id]); + `, + [req.body.id, req.body.project_id, req.body.parent_task_id] + ); q = `SELECT handle_on_task_phase_change($1, $2);`; } - if (req.body.to_group_id === UNMAPPED) - req.body.to_group_id = null; + if (req.body.to_group_id === UNMAPPED) req.body.to_group_id = null; - const params = groupType === "phase" - ? [req.body.id, req.body.to_group_id] - : [req.body.id, req.body.project_id, req.body.parent_task_id, req.body.to_group_id]; + const params = + groupType === "phase" + ? [req.body.id, req.body.to_group_id] + : [ + req.body.id, + req.body.project_id, + req.body.parent_task_id, + req.body.to_group_id, + ]; await db.query(q, params); // Reset the parent task's manual progress when converting a task to a subtask @@ -624,7 +752,9 @@ export default class TasksControllerV2 extends TasksControllerBase { await this.resetParentTaskManualProgress(req.body.parent_task_id); } - const result = await db.query("SELECT get_single_task($1) AS task;", [req.body.id]); + const result = await db.query("SELECT get_single_task($1) AS task;", [ + req.body.id, + ]); const [data] = result.rows; const model = TasksControllerV2.updateTaskViewModel(data.task); return res.status(200).send(new ServerResponse(true, model)); @@ -639,8 +769,7 @@ export default class TasksControllerV2 extends TasksControllerBase { `; const result = await db.query(q, [taskId]); - for (const member of result.rows) - member.color_code = getColor(member.name); + for (const member of result.rows) member.color_code = getColor(member.name); return this.createTagList(result.rows); } @@ -654,13 +783,16 @@ export default class TasksControllerV2 extends TasksControllerBase { `; const result = await db.query(q, [projectId]); - for (const member of result.rows) - member.color_code = getColor(member.name); + for (const member of result.rows) member.color_code = getColor(member.name); return this.createTagList(result.rows); } - public static async checkUserAssignedToTask(taskId: string, userId: string, teamId: string) { + public static async checkUserAssignedToTask( + taskId: string, + userId: string, + teamId: string + ) { const q = ` SELECT EXISTS( SELECT * FROM tasks_assignees WHERE task_id = $1 AND team_member_id = (SELECT team_member_id FROM team_member_info_view WHERE user_id = $2 AND team_id = $3) @@ -670,10 +802,13 @@ export default class TasksControllerV2 extends TasksControllerBase { const [data] = result.rows; return data.exists; - } - public static async getTasksByName(searchString: string, projectId: string, taskId: string) { + public static async getTasksByName( + searchString: string, + projectId: string, + taskId: string + ) { const q = `SELECT id AS value , name AS label, CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no) AS task_key @@ -687,27 +822,48 @@ export default class TasksControllerV2 extends TasksControllerBase { } @HandleExceptions() - public static async getSubscribers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async getSubscribers( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { const subscribers = await this.getTaskSubscribers(req.params.id); return res.status(200).send(new ServerResponse(true, subscribers)); } @HandleExceptions() - public static async searchTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async searchTasks( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { const { projectId, taskId, searchQuery } = req.query; - const tasks = await this.getTasksByName(searchQuery as string, projectId as string, taskId as string); + const tasks = await this.getTasksByName( + searchQuery as string, + projectId as string, + taskId as string + ); return res.status(200).send(new ServerResponse(true, tasks)); } @HandleExceptions() - public static async getTaskDependencyStatus(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async getTaskDependencyStatus( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { const { statusId, taskId } = req.query; - const canContinue = await TasksControllerV2.checkForCompletedDependencies(taskId as string, statusId as string); - return res.status(200).send(new ServerResponse(true, { can_continue: canContinue })); + const canContinue = await TasksControllerV2.checkForCompletedDependencies( + taskId as string, + statusId as string + ); + return res + .status(200) + .send(new ServerResponse(true, { can_continue: canContinue })); } @HandleExceptions() - public static async checkForCompletedDependencies(taskId: string, nextStatusId: string): Promise { + public static async checkForCompletedDependencies( + taskId: string, + nextStatusId: string + ): Promise { const q = `SELECT CASE WHEN EXISTS ( @@ -759,7 +915,10 @@ export default class TasksControllerV2 extends TasksControllerBase { } @HandleExceptions() - public static async assignLabelsToTask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async assignLabelsToTask( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { const { id } = req.params; const { labels }: { labels: string[] } = req.body; @@ -767,7 +926,9 @@ export default class TasksControllerV2 extends TasksControllerBase { const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`; await db.query(q, [id, label]); }); - return res.status(200).send(new ServerResponse(true, null, "Labels assigned successfully")); + return res + .status(200) + .send(new ServerResponse(true, null, "Labels assigned successfully")); } /** @@ -784,7 +945,9 @@ export default class TasksControllerV2 extends TasksControllerBase { const { column_key, value, project_id } = req.body; if (!taskId || !column_key || value === undefined || !project_id) { - return res.status(400).send(new ServerResponse(false, "Missing required parameters")); + return res + .status(400) + .send(new ServerResponse(false, "Missing required parameters")); } // Get column information @@ -796,7 +959,9 @@ export default class TasksControllerV2 extends TasksControllerBase { const columnResult = await db.query(columnQuery, [project_id, column_key]); if (columnResult.rowCount === 0) { - return res.status(404).send(new ServerResponse(false, "Custom column not found")); + return res + .status(404) + .send(new ServerResponse(false, "Custom column not found")); } const column = columnResult.rows[0]; @@ -833,7 +998,10 @@ export default class TasksControllerV2 extends TasksControllerBase { FROM cc_column_values WHERE task_id = $1 AND column_id = $2 `; - const existingValueResult = await db.query(existingValueQuery, [taskId, columnId]); + const existingValueResult = await db.query(existingValueQuery, [ + taskId, + columnId, + ]); if (existingValueResult.rowCount && existingValueResult.rowCount > 0) { // Update existing value @@ -854,7 +1022,7 @@ export default class TasksControllerV2 extends TasksControllerBase { booleanValue, jsonValue, taskId, - columnId + columnId, ]); } else { // Insert new value @@ -870,18 +1038,22 @@ export default class TasksControllerV2 extends TasksControllerBase { numberValue, dateValue, booleanValue, - jsonValue + jsonValue, ]); } - return res.status(200).send(new ServerResponse(true, { - task_id: taskId, - column_key, - value - })); + return res.status(200).send( + new ServerResponse(true, { + task_id: taskId, + column_key, + value, + }) + ); } - public static async refreshProjectTaskProgressValues(projectId: string): Promise { + public static async refreshProjectTaskProgressValues( + projectId: string + ): Promise { try { // Run the recalculate_all_task_progress function only for tasks in this project const query = ` @@ -941,7 +1113,9 @@ export default class TasksControllerV2 extends TasksControllerBase { `; await db.query(query); - console.log(`Finished refreshing progress values for project ${projectId}`); + console.log( + `Finished refreshing progress values for project ${projectId}` + ); } catch (error) { log_error("Error refreshing project task progress values", error); } @@ -950,17 +1124,20 @@ export default class TasksControllerV2 extends TasksControllerBase { public static async updateTaskProgress(taskId: string): Promise { try { // Calculate the task's progress using get_task_complete_ratio - const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]); + const result = await db.query( + "SELECT get_task_complete_ratio($1) AS info;", + [taskId] + ); const [data] = result.rows; if (data && data.info && data.info.ratio !== undefined) { - const progressValue = +((data.info.ratio || 0).toFixed()); + const progressValue = +(data.info.ratio || 0).toFixed(); // Update the task's progress_value in the database - await db.query( - "UPDATE tasks SET progress_value = $1 WHERE id = $2", - [progressValue, taskId] - ); + await db.query("UPDATE tasks SET progress_value = $1 WHERE id = $2", [ + progressValue, + taskId, + ]); console.log(`Updated progress for task ${taskId} to ${progressValue}%`); @@ -970,7 +1147,10 @@ export default class TasksControllerV2 extends TasksControllerBase { [taskId] ); - if (parentResult.rows.length > 0 && parentResult.rows[0].parent_task_id) { + if ( + parentResult.rows.length > 0 && + parentResult.rows[0].parent_task_id + ) { await this.updateTaskProgress(parentResult.rows[0].parent_task_id); } } @@ -980,13 +1160,16 @@ export default class TasksControllerV2 extends TasksControllerBase { } // Add this method to update progress when a task's weight is changed - public static async updateTaskWeight(taskId: string, weight: number): Promise { + public static async updateTaskWeight( + taskId: string, + weight: number + ): Promise { try { // Update the task's weight - await db.query( - "UPDATE tasks SET weight = $1 WHERE id = $2", - [weight, taskId] - ); + await db.query("UPDATE tasks SET weight = $1 WHERE id = $2", [ + weight, + taskId, + ]); // Get the parent task ID const parentResult = await db.query( @@ -1004,11 +1187,13 @@ export default class TasksControllerV2 extends TasksControllerBase { } @HandleExceptions() - public static async getTasksV3(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async getTasksV3( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { const startTime = performance.now(); const isSubTasks = !!req.query.parent_task; const groupBy = (req.query.group || GroupBy.STATUS) as string; - const archived = req.query.archived === "true"; // PERFORMANCE OPTIMIZATION: Skip expensive progress calculation by default // Progress values are already calculated and stored in the database @@ -1017,29 +1202,25 @@ export default class TasksControllerV2 extends TasksControllerBase { const shouldRefreshProgress = req.query.refresh_progress === "true"; if (shouldRefreshProgress && req.params.id) { - const progressStartTime = performance.now(); await this.refreshProjectTaskProgressValues(req.params.id); - const progressEndTime = performance.now(); } - const queryStartTime = performance.now(); const q = TasksControllerV2.getQuery(req.user?.id as string, req.query); - const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; + const params = isSubTasks + ? [req.params.id || null, req.query.parent_task] + : [req.params.id || null]; const result = await db.query(q, params); const tasks = [...result.rows]; - const queryEndTime = performance.now(); // Get groups metadata dynamically from database - const groupsStartTime = performance.now(); const groups = await this.getGroups(groupBy, req.params.id); - const groupsEndTime = performance.now(); // Create priority value to name mapping const priorityMap: Record = { "0": "low", "1": "medium", - "2": "high" + "2": "high", }; // Create status category mapping based on actual status names from database @@ -1047,14 +1228,13 @@ export default class TasksControllerV2 extends TasksControllerBase { for (const group of groups) { if (groupBy === GroupBy.STATUS && group.id) { // Use the actual status name from database, convert to lowercase for consistency - statusCategoryMap[group.id] = group.name.toLowerCase().replace(/\s+/g, "_"); + statusCategoryMap[group.id] = group.name + .toLowerCase() + .replace(/\s+/g, "_"); } } - - // Transform tasks with all necessary data preprocessing - const transformStartTime = performance.now(); const transformedTasks = tasks.map((task, index) => { // Update task with calculated values (lightweight version) TasksControllerV2.updateTaskViewModel(task); @@ -1071,7 +1251,7 @@ export default class TasksControllerV2 extends TasksControllerBase { if ("hours" in value || "minutes" in value) { const hours = Number(value.hours || 0); const minutes = Number(value.minutes || 0); - return hours + (minutes / 60); + return hours + minutes / 60; } } return 0; @@ -1088,16 +1268,18 @@ export default class TasksControllerV2 extends TasksControllerBase { priority: priorityMap[task.priority_value?.toString()] || "medium", // Use actual phase name from database phase: task.phase_name || "Development", - progress: typeof task.complete_ratio === "number" ? task.complete_ratio : 0, + progress: + typeof task.complete_ratio === "number" ? task.complete_ratio : 0, assignees: task.assignees?.map((a: any) => a.team_member_id) || [], assignee_names: task.assignee_names || task.names || [], - labels: task.labels?.map((l: any) => ({ - id: l.id || l.label_id, - name: l.name, - color: l.color_code || "#1890ff", - end: l.end, - names: l.names - })) || [], + labels: + task.labels?.map((l: any) => ({ + id: l.id || l.label_id, + name: l.name, + color: l.color_code || "#1890ff", + end: l.end, + names: l.names, + })) || [], dueDate: task.end_date || task.END_DATE, startDate: task.start_date, timeTracking: { @@ -1125,19 +1307,17 @@ export default class TasksControllerV2 extends TasksControllerBase { reporter: task.reporter || null, }; }); - const transformEndTime = performance.now(); - - // Create groups based on dynamic data from database - const groupingStartTime = performance.now(); const groupedResponse: Record = {}; // Initialize groups from database data - groups.forEach(group => { - const groupKey = groupBy === GroupBy.STATUS - ? group.name.toLowerCase().replace(/\s+/g, "_") - : groupBy === GroupBy.PRIORITY - ? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase() - : group.name.toLowerCase().replace(/\s+/g, "_"); + groups.forEach((group) => { + const groupKey = + groupBy === GroupBy.STATUS + ? group.name.toLowerCase().replace(/\s+/g, "_") + : groupBy === GroupBy.PRIORITY + ? priorityMap[(group as any).value?.toString()] || + group.name.toLowerCase() + : group.name.toLowerCase().replace(/\s+/g, "_"); groupedResponse[groupKey] = { id: group.id, @@ -1148,6 +1328,8 @@ export default class TasksControllerV2 extends TasksControllerBase { tasks: [], taskIds: [], color: group.color_code || this.getDefaultGroupColor(groupBy, groupKey), + color_code_dark: + group.color_code_dark || this.getDefaultGroupColor(groupBy, groupKey), // Include additional metadata from database category_id: group.category_id, start_date: group.start_date, @@ -1159,7 +1341,7 @@ export default class TasksControllerV2 extends TasksControllerBase { // Distribute tasks into groups const unmappedTasks: any[] = []; - transformedTasks.forEach(task => { + transformedTasks.forEach((task) => { let groupKey: string; let taskAssigned = false; @@ -1200,27 +1382,33 @@ export default class TasksControllerV2 extends TasksControllerBase { if (group.tasks && group.tasks.length > 0) { const todoCount = group.tasks.filter((task: any) => { // For tasks, we need to check their original status category - const originalTask = tasks.find(t => t.id === task.id); + const originalTask = tasks.find((t) => t.id === task.id); return originalTask?.status_category?.is_todo; }).length; - + const doingCount = group.tasks.filter((task: any) => { - const originalTask = tasks.find(t => t.id === task.id); + const originalTask = tasks.find((t) => t.id === task.id); return originalTask?.status_category?.is_doing; }).length; - + const doneCount = group.tasks.filter((task: any) => { - const originalTask = tasks.find(t => t.id === task.id); + const originalTask = tasks.find((t) => t.id === task.id); return originalTask?.status_category?.is_done; }).length; const total = group.tasks.length; // Calculate progress percentages - group.todo_progress = total > 0 ? +((todoCount / total) * 100).toFixed(0) : 0; - group.doing_progress = total > 0 ? +((doingCount / total) * 100).toFixed(0) : 0; - group.done_progress = total > 0 ? +((doneCount / total) * 100).toFixed(0) : 0; + group.todo_progress = + total > 0 ? +((todoCount / total) * 100).toFixed(0) : 0; + group.doing_progress = + total > 0 ? +((doingCount / total) * 100).toFixed(0) : 0; + group.done_progress = + total > 0 ? +((doneCount / total) * 100).toFixed(0) : 0; } + group.todo_progress = 0; + group.doing_progress = 0; + group.done_progress = 0; }); } @@ -1233,7 +1421,7 @@ export default class TasksControllerV2 extends TasksControllerBase { groupValue: UNMAPPED.toLowerCase(), collapsed: false, tasks: unmappedTasks, - taskIds: unmappedTasks.map(task => task.id), + taskIds: unmappedTasks.map((task) => task.id), color: "#fbc84c69", // Orange color with transparency category_id: null, start_date: null, @@ -1247,25 +1435,28 @@ export default class TasksControllerV2 extends TasksControllerBase { // Calculate progress stats for unmapped group if (unmappedTasks.length > 0) { const todoCount = unmappedTasks.filter((task: any) => { - const originalTask = tasks.find(t => t.id === task.id); + const originalTask = tasks.find((t) => t.id === task.id); return originalTask?.status_category?.is_todo; }).length; - + const doingCount = unmappedTasks.filter((task: any) => { - const originalTask = tasks.find(t => t.id === task.id); + const originalTask = tasks.find((t) => t.id === task.id); return originalTask?.status_category?.is_doing; }).length; - + const doneCount = unmappedTasks.filter((task: any) => { - const originalTask = tasks.find(t => t.id === task.id); + const originalTask = tasks.find((t) => t.id === task.id); return originalTask?.status_category?.is_done; }).length; const total = unmappedTasks.length; - unmappedGroup.todo_progress = total > 0 ? +((todoCount / total) * 100).toFixed(0) : 0; - unmappedGroup.doing_progress = total > 0 ? +((doingCount / total) * 100).toFixed(0) : 0; - unmappedGroup.done_progress = total > 0 ? +((doneCount / total) * 100).toFixed(0) : 0; + unmappedGroup.todo_progress = + total > 0 ? +((todoCount / total) * 100).toFixed(0) : 0; + unmappedGroup.doing_progress = + total > 0 ? +((doingCount / total) * 100).toFixed(0) : 0; + unmappedGroup.done_progress = + total > 0 ? +((doneCount / total) * 100).toFixed(0) : 0; } groupedResponse[UNMAPPED.toLowerCase()] = unmappedGroup; @@ -1278,54 +1469,73 @@ export default class TasksControllerV2 extends TasksControllerBase { // Convert to array format expected by frontend, maintaining database order const responseGroups = groups - .map(group => { - const groupKey = groupBy === GroupBy.STATUS - ? group.name.toLowerCase().replace(/\s+/g, "_") - : groupBy === GroupBy.PRIORITY - ? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase() - : group.name.toLowerCase().replace(/\s+/g, "_"); + .map((group) => { + const groupKey = + groupBy === GroupBy.STATUS + ? group.name.toLowerCase().replace(/\s+/g, "_") + : groupBy === GroupBy.PRIORITY + ? priorityMap[(group as any).value?.toString()] || + group.name.toLowerCase() + : group.name.toLowerCase().replace(/\s+/g, "_"); return groupedResponse[groupKey]; }) - .filter(group => group && (group.tasks.length > 0 || req.query.include_empty === "true")); + .filter( + (group) => + group && + (group.tasks.length > 0 || req.query.include_empty === "true") + ); // Add unmapped group to the end if it exists if (groupedResponse[UNMAPPED.toLowerCase()]) { responseGroups.push(groupedResponse[UNMAPPED.toLowerCase()]); } - const groupingEndTime = performance.now(); - const endTime = performance.now(); const totalTime = endTime - startTime; // Log warning if request is taking too long if (totalTime > 1000) { - console.warn(`[PERFORMANCE WARNING] Slow request detected: ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks`); + console.warn( + `[PERFORMANCE WARNING] Slow request detected: ${totalTime.toFixed( + 2 + )}ms for project ${req.params.id} with ${transformedTasks.length} tasks` + ); } - return res.status(200).send(new ServerResponse(true, { - groups: responseGroups, - allTasks: transformedTasks, - grouping: groupBy, - totalTasks: transformedTasks.length - })); + return res.status(200).send( + new ServerResponse(true, { + groups: responseGroups, + allTasks: transformedTasks, + grouping: groupBy, + totalTasks: transformedTasks.length, + }) + ); } private static getTaskSortOrder(task: any, groupBy: string): number { switch (groupBy) { case GroupBy.STATUS: - return typeof task.status_sort_order === "number" ? task.status_sort_order : 0; + return typeof task.status_sort_order === "number" + ? task.status_sort_order + : 0; case GroupBy.PRIORITY: - return typeof task.priority_sort_order === "number" ? task.priority_sort_order : 0; + return typeof task.priority_sort_order === "number" + ? task.priority_sort_order + : 0; case GroupBy.PHASE: - return typeof task.phase_sort_order === "number" ? task.phase_sort_order : 0; + return typeof task.phase_sort_order === "number" + ? task.phase_sort_order + : 0; default: return typeof task.sort_order === "number" ? task.sort_order : 0; } } - private static getDefaultGroupColor(groupBy: string, groupValue: string): string { + private static getDefaultGroupColor( + groupBy: string, + groupValue: string + ): string { const colorMaps: Record> = { [GroupBy.STATUS]: { todo: "#f0f0f0", @@ -1333,16 +1543,11 @@ export default class TasksControllerV2 extends TasksControllerBase { done: "#52c41a", }, [GroupBy.PRIORITY]: { - critical: "#ff4d4f", high: "#ff7a45", medium: "#faad14", low: "#52c41a", }, [GroupBy.PHASE]: { - planning: "#722ed1", - development: "#1890ff", - testing: "#faad14", - deployment: "#52c41a", unmapped: "#fbc84c69", }, }; @@ -1351,43 +1556,66 @@ export default class TasksControllerV2 extends TasksControllerBase { } @HandleExceptions() - public static async refreshTaskProgress(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async refreshTaskProgress( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { try { const startTime = performance.now(); if (req.params.id) { - console.log(`[PERFORMANCE] Starting background progress refresh for project ${req.params.id}`); + console.log( + `[PERFORMANCE] Starting background progress refresh for project ${req.params.id}` + ); await this.refreshProjectTaskProgressValues(req.params.id); const endTime = performance.now(); const totalTime = endTime - startTime; - console.log(`[PERFORMANCE] Background progress refresh completed in ${totalTime.toFixed(2)}ms for project ${req.params.id}`); + console.log( + `[PERFORMANCE] Background progress refresh completed in ${totalTime.toFixed( + 2 + )}ms for project ${req.params.id}` + ); - return res.status(200).send(new ServerResponse(true, { - message: "Task progress values refreshed successfully", - performanceMetrics: { - refreshTime: Math.round(totalTime), - projectId: req.params.id - } - })); + return res.status(200).send( + new ServerResponse(true, { + message: "Task progress values refreshed successfully", + performanceMetrics: { + refreshTime: Math.round(totalTime), + projectId: req.params.id, + }, + }) + ); } - return res.status(400).send(new ServerResponse(false, null, "Project ID is required")); + return res + .status(400) + .send(new ServerResponse(false, null, "Project ID is required")); } catch (error) { console.error("Error refreshing task progress:", error); - return res.status(500).send(new ServerResponse(false, null, "Failed to refresh task progress")); + return res + .status(500) + .send( + new ServerResponse(false, null, "Failed to refresh task progress") + ); } } // Optimized method for getting task progress without blocking main UI @HandleExceptions() - public static async getTaskProgressStatus(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async getTaskProgressStatus( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { try { if (!req.params.id) { - return res.status(400).send(new ServerResponse(false, null, "Project ID is required")); + return res + .status(400) + .send(new ServerResponse(false, null, "Project ID is required")); } // Get basic progress stats without expensive calculations - const result = await db.query(` + const result = await db.query( + ` SELECT COUNT(*) as total_tasks, COUNT(CASE WHEN EXISTS( @@ -1402,22 +1630,36 @@ export default class TasksControllerV2 extends TasksControllerBase { MAX(updated_at) as last_updated FROM tasks WHERE project_id = $1 AND archived IS FALSE - `, [req.params.id]); + `, + [req.params.id] + ); const [stats] = result.rows; - return res.status(200).send(new ServerResponse(true, { - projectId: req.params.id, - totalTasks: parseInt(stats.total_tasks) || 0, - completedTasks: parseInt(stats.completed_tasks) || 0, - avgProgress: parseFloat(stats.avg_progress) || 0, - lastUpdated: stats.last_updated, - completionPercentage: stats.total_tasks > 0 ? - Math.round((parseInt(stats.completed_tasks) / parseInt(stats.total_tasks)) * 100) : 0 - })); + return res.status(200).send( + new ServerResponse(true, { + projectId: req.params.id, + totalTasks: parseInt(stats.total_tasks) || 0, + completedTasks: parseInt(stats.completed_tasks) || 0, + avgProgress: parseFloat(stats.avg_progress) || 0, + lastUpdated: stats.last_updated, + completionPercentage: + stats.total_tasks > 0 + ? Math.round( + (parseInt(stats.completed_tasks) / + parseInt(stats.total_tasks)) * + 100 + ) + : 0, + }) + ); } catch (error) { console.error("Error getting task progress status:", error); - return res.status(500).send(new ServerResponse(false, null, "Failed to get task progress status")); + return res + .status(500) + .send( + new ServerResponse(false, null, "Failed to get task progress status") + ); } } } diff --git a/worklenz-backend/src/shared/constants.ts b/worklenz-backend/src/shared/constants.ts index c814c603..ffda9e67 100644 --- a/worklenz-backend/src/shared/constants.ts +++ b/worklenz-backend/src/shared/constants.ts @@ -89,24 +89,24 @@ export const NumbersColorMap: { [x: string]: string } = { }; export const PriorityColorCodes: { [x: number]: string; } = { - 0: "#75c997", - 1: "#fbc84c", - 2: "#f37070" + 0: "#2E8B57", + 1: "#DAA520", + 2: "#CD5C5C" }; export const PriorityColorCodesDark: { [x: number]: string; } = { - 0: "#46D980", - 1: "#FFC227", - 2: "#FF4141" + 0: "#3CB371", + 1: "#B8860B", + 2: "#F08080" }; export const TASK_STATUS_TODO_COLOR = "#a9a9a9"; export const TASK_STATUS_DOING_COLOR = "#70a6f3"; export const TASK_STATUS_DONE_COLOR = "#75c997"; -export const TASK_PRIORITY_LOW_COLOR = "#75c997"; -export const TASK_PRIORITY_MEDIUM_COLOR = "#fbc84c"; -export const TASK_PRIORITY_HIGH_COLOR = "#f37070"; +export const TASK_PRIORITY_LOW_COLOR = "#2E8B57"; +export const TASK_PRIORITY_MEDIUM_COLOR = "#DAA520"; +export const TASK_PRIORITY_HIGH_COLOR = "#CD5C5C"; export const TASK_DUE_COMPLETED_COLOR = "#75c997"; export const TASK_DUE_UPCOMING_COLOR = "#70a6f3"; diff --git a/worklenz-frontend/public/locales/alb/auth/signup.json b/worklenz-frontend/public/locales/alb/auth/signup.json index 1dac7a39..17426508 100644 --- a/worklenz-frontend/public/locales/alb/auth/signup.json +++ b/worklenz-frontend/public/locales/alb/auth/signup.json @@ -7,11 +7,13 @@ "emailLabel": "Email", "emailPlaceholder": "Shkruani email-in tuaj", "emailRequired": "Ju lutemi shkruani Email-in tuaj!", - "passwordLabel": "Fjalëkalimi", - "passwordPlaceholder": "Krijoni një fjalëkalim", + "passwordLabel": "Password", + "passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.", + "passwordPlaceholder": "Enter your password", "passwordRequired": "Ju lutemi krijoni një Fjalëkalim!", "passwordMinCharacterRequired": "Fjalëkalimi duhet të jetë së paku 8 karaktere!", - "passwordPatternRequired": "Fjalëkalimi nuk plotëson kërkesat!", + "passwordMaxCharacterRequired": "Password must be at most 32 characters!", + "passwordPatternRequired": "Fjalëkalimi nuk i plotëson kërkesat!", "strongPasswordPlaceholder": "Vendosni një fjalëkalim më të fortë", "passwordValidationAltText": "Fjalëkalimi duhet të përmbajë së paku 8 karaktere me shkronja të mëdha dhe të vogla, një numër dhe një simbol.", "signupSuccessMessage": "Jeni regjistruar me sukses!", diff --git a/worklenz-frontend/public/locales/de/auth/signup.json b/worklenz-frontend/public/locales/de/auth/signup.json index 55a63a23..8eb7e5a3 100644 --- a/worklenz-frontend/public/locales/de/auth/signup.json +++ b/worklenz-frontend/public/locales/de/auth/signup.json @@ -7,11 +7,13 @@ "emailLabel": "E-Mail", "emailPlaceholder": "Ihre E-Mail-Adresse eingeben", "emailRequired": "Bitte geben Sie Ihre E-Mail-Adresse ein!", - "passwordLabel": "Passwort", - "passwordPlaceholder": "Ihr Passwort eingeben", + "passwordLabel": "Password", + "passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.", + "passwordPlaceholder": "Enter your password", "passwordRequired": "Bitte geben Sie Ihr Passwort ein!", "passwordMinCharacterRequired": "Das Passwort muss mindestens 8 Zeichen lang sein!", - "passwordPatternRequired": "Das Passwort erfüllt nicht die Anforderungen!", + "passwordMaxCharacterRequired": "Password must be at most 32 characters!", + "passwordPatternRequired": "Das Passwort entspricht nicht den Anforderungen!", "strongPasswordPlaceholder": "Ein stärkeres Passwort eingeben", "passwordValidationAltText": "Das Passwort muss mindestens 8 Zeichen enthalten, mit Groß- und Kleinbuchstaben, einer Zahl und einem Sonderzeichen.", "signupSuccessMessage": "Sie haben sich erfolgreich registriert!", diff --git a/worklenz-frontend/public/locales/en/auth/signup.json b/worklenz-frontend/public/locales/en/auth/signup.json index af4611ba..c40eb9e7 100644 --- a/worklenz-frontend/public/locales/en/auth/signup.json +++ b/worklenz-frontend/public/locales/en/auth/signup.json @@ -8,9 +8,11 @@ "emailPlaceholder": "Enter your email", "emailRequired": "Please enter your Email!", "passwordLabel": "Password", + "passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.", "passwordPlaceholder": "Enter your password", "passwordRequired": "Please enter your Password!", "passwordMinCharacterRequired": "Password must be at least 8 characters!", + "passwordMaxCharacterRequired": "Password must be at most 32 characters!", "passwordPatternRequired": "Password does not meet the requirements!", "strongPasswordPlaceholder": "Enter a stronger password", "passwordValidationAltText": "Password must include at least 8 characters with upper and lower case letters, a number, and a symbol.", diff --git a/worklenz-frontend/public/locales/es/auth/signup.json b/worklenz-frontend/public/locales/es/auth/signup.json index 465ff287..2dbd0188 100644 --- a/worklenz-frontend/public/locales/es/auth/signup.json +++ b/worklenz-frontend/public/locales/es/auth/signup.json @@ -7,10 +7,12 @@ "emailLabel": "Correo electrónico", "emailPlaceholder": "Ingresa tu correo electrónico", "emailRequired": "¡Por favor ingresa tu correo electrónico!", - "passwordLabel": "Contraseña", - "passwordPlaceholder": "Ingresa tu contraseña", + "passwordLabel": "Password", + "passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.", + "passwordPlaceholder": "Enter your password", "passwordRequired": "¡Por favor ingresa tu contraseña!", "passwordMinCharacterRequired": "¡La contraseña debe tener al menos 8 caracteres!", + "passwordMaxCharacterRequired": "Password must be at most 32 characters!", "passwordPatternRequired": "¡La contraseña no cumple con los requisitos!", "strongPasswordPlaceholder": "Ingresa una contraseña más segura", "passwordValidationAltText": "La contraseña debe incluir al menos 8 caracteres con letras mayúsculas y minúsculas, un número y un símbolo.", diff --git a/worklenz-frontend/public/locales/pt/auth/signup.json b/worklenz-frontend/public/locales/pt/auth/signup.json index cd994d4a..b6c55121 100644 --- a/worklenz-frontend/public/locales/pt/auth/signup.json +++ b/worklenz-frontend/public/locales/pt/auth/signup.json @@ -7,11 +7,13 @@ "emailLabel": "Email", "emailPlaceholder": "Insira seu email", "emailRequired": "Por favor, insira seu Email!", - "passwordLabel": "Senha", - "passwordPlaceholder": "Insira sua senha", + "passwordLabel": "Password", + "passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.", + "passwordPlaceholder": "Enter your password", "passwordRequired": "Por favor, insira sua Senha!", "passwordMinCharacterRequired": "Senha deve ter pelo menos 8 caracteres!", - "passwordPatternRequired": "Senha não atende aos requisitos!", + "passwordMaxCharacterRequired": "Password must be at most 32 characters!", + "passwordPatternRequired": "A senha não atende aos requisitos!", "strongPasswordPlaceholder": "Insira uma senha mais forte", "passwordValidationAltText": "Senha deve incluir pelo menos 8 caracteres com letras maiúsculas e minúsculas, um número e um símbolo.", "signupSuccessMessage": "Você se inscreveu com sucesso!", diff --git a/worklenz-frontend/public/locales/zh/auth/signup.json b/worklenz-frontend/public/locales/zh/auth/signup.json index a2b34e57..d2938d64 100644 --- a/worklenz-frontend/public/locales/zh/auth/signup.json +++ b/worklenz-frontend/public/locales/zh/auth/signup.json @@ -7,10 +7,12 @@ "emailLabel": "电子邮件", "emailPlaceholder": "输入您的电子邮件", "emailRequired": "请输入您的电子邮件!", - "passwordLabel": "密码", - "passwordPlaceholder": "输入您的密码", + "passwordLabel": "Password", + "passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.", + "passwordPlaceholder": "Enter your password", "passwordRequired": "请输入您的密码!", "passwordMinCharacterRequired": "密码必须至少包含8个字符!", + "passwordMaxCharacterRequired": "Password must be at most 32 characters!", "passwordPatternRequired": "密码不符合要求!", "strongPasswordPlaceholder": "输入更强的密码", "passwordValidationAltText": "密码必须至少包含8个字符,包括大小写字母、一个数字和一个符号。", diff --git a/worklenz-frontend/src/app/routes/auth-routes.tsx b/worklenz-frontend/src/app/routes/auth-routes.tsx index 5cddb925..b0909963 100644 --- a/worklenz-frontend/src/app/routes/auth-routes.tsx +++ b/worklenz-frontend/src/app/routes/auth-routes.tsx @@ -4,12 +4,12 @@ import { Navigate } from 'react-router-dom'; import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; // Lazy load auth page components for better code splitting -const LoginPage = lazy(() => import('@/pages/auth/login-page')); -const SignupPage = lazy(() => import('@/pages/auth/signup-page')); -const ForgotPasswordPage = lazy(() => import('@/pages/auth/forgot-password-page')); -const LoggingOutPage = lazy(() => import('@/pages/auth/logging-out')); -const AuthenticatingPage = lazy(() => import('@/pages/auth/authenticating')); -const VerifyResetEmailPage = lazy(() => import('@/pages/auth/verify-reset-email')); +const LoginPage = lazy(() => import('@/pages/auth/LoginPage')); +const SignupPage = lazy(() => import('@/pages/auth/SignupPage')); +const ForgotPasswordPage = lazy(() => import('@/pages/auth/ForgotPasswordPage')); +const LoggingOutPage = lazy(() => import('@/pages/auth/LoggingOutPage')); +const AuthenticatingPage = lazy(() => import('@/pages/auth/AuthenticatingPage')); +const VerifyResetEmailPage = lazy(() => import('@/pages/auth/VerifyResetEmailPage')); const authRoutes = [ { diff --git a/worklenz-frontend/src/components/EmptyListPlaceholder.tsx b/worklenz-frontend/src/components/EmptyListPlaceholder.tsx index dfe1aa76..58cf691e 100644 --- a/worklenz-frontend/src/components/EmptyListPlaceholder.tsx +++ b/worklenz-frontend/src/components/EmptyListPlaceholder.tsx @@ -1,17 +1,24 @@ import { Empty, Typography } from 'antd'; import React from 'react'; +import { useTranslation } from 'react-i18next'; type EmptyListPlaceholderProps = { imageSrc?: string; imageHeight?: number; - text: string; + text?: string; + textKey?: string; + i18nNs?: string; }; const EmptyListPlaceholder = ({ imageSrc = 'https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp', imageHeight = 60, text, + textKey, + i18nNs = 'task-list-table', }: EmptyListPlaceholderProps) => { + const { t } = useTranslation(i18nNs); + const description = textKey ? t(textKey) : text; return ( {text}} + description={{description}} /> ); }; diff --git a/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx b/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx index a8623d27..0d17c779 100644 --- a/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx +++ b/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import { Tooltip } from 'antd'; interface GroupProgressBarProps { todoProgress: number; @@ -15,24 +16,34 @@ const GroupProgressBar: React.FC = ({ groupType }) => { const { t } = useTranslation('task-management'); + console.log(todoProgress, doingProgress, doneProgress); // Only show for priority and phase grouping if (groupType !== 'priority' && groupType !== 'phase') { return null; } - const total = todoProgress + doingProgress + doneProgress; + const total = (todoProgress || 0) + (doingProgress || 0) + (doneProgress || 0); // Don't show if no progress values exist if (total === 0) { return null; } + // Tooltip content with all values in rows + const tooltipContent = ( +
+
{t('todo')}: {todoProgress || 0}%
+
{t('inProgress')}: {doingProgress || 0}%
+
{t('done')}: {doneProgress || 0}%
+
+ ); + return (
{/* Compact progress text */} - {doneProgress}% {t('done')} + {doneProgress || 0}% {t('done')} {/* Compact progress bar */} @@ -40,27 +51,30 @@ const GroupProgressBar: React.FC = ({
{/* Todo section - light green */} {todoProgress > 0 && ( -
+ +
+ )} {/* Doing section - medium green */} {doingProgress > 0 && ( -
+ +
+ )} {/* Done section - dark green */} {doneProgress > 0 && ( -
+ +
+ )}
@@ -68,22 +82,25 @@ const GroupProgressBar: React.FC = ({ {/* Small legend dots with better spacing */}
{todoProgress > 0 && ( -
+ +
+ )} {doingProgress > 0 && ( -
+ +
+ )} {doneProgress > 0 && ( -
+ +
+ )}
diff --git a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx index d3f2e5b7..27adf3be 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx @@ -1,15 +1,29 @@ import React, { useMemo, useCallback, useState } from 'react'; import { useDroppable } from '@dnd-kit/core'; // @ts-ignore: Heroicons module types -import { ChevronDownIcon, ChevronRightIcon, EllipsisHorizontalIcon, PencilIcon, ArrowPathIcon } from '@heroicons/react/24/outline'; +import { + ChevronDownIcon, + ChevronRightIcon, + EllipsisHorizontalIcon, + PencilIcon, + ArrowPathIcon, +} from '@heroicons/react/24/outline'; import { Checkbox, Dropdown, Menu, Input, Modal, Badge, Flex } from 'antd'; import GroupProgressBar from './GroupProgressBar'; import { useTranslation } from 'react-i18next'; import { getContrastColor } from '@/utils/colorUtils'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { selectSelectedTaskIds, selectTask, deselectTask } from '@/features/task-management/selection.slice'; -import { selectGroups, fetchTasksV3, selectAllTasksArray } from '@/features/task-management/task-management.slice'; +import { + selectSelectedTaskIds, + selectTask, + deselectTask, +} from '@/features/task-management/selection.slice'; +import { + selectGroups, + fetchTasksV3, + selectAllTasksArray, +} from '@/features/task-management/task-management.slice'; import { selectCurrentGrouping } from '@/features/task-management/grouping.slice'; import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service'; @@ -38,7 +52,12 @@ interface TaskGroupHeaderProps { projectId: string; } -const TaskGroupHeader: React.FC = ({ group, isCollapsed, onToggle, projectId }) => { +const TaskGroupHeader: React.FC = ({ + group, + isCollapsed, + onToggle, + projectId, +}) => { const { t } = useTranslation('task-management'); const dispatch = useAppDispatch(); const selectedTaskIds = useAppSelector(selectSelectedTaskIds); @@ -48,14 +67,14 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o const { statusCategories, status: statusList } = useAppSelector(state => state.taskStatusReducer); const { trackMixpanelEvent } = useMixpanelTracking(); const { isOwnerOrAdmin } = useAuthService(); - + const [dropdownVisible, setDropdownVisible] = useState(false); const [isRenaming, setIsRenaming] = useState(false); const [isChangingCategory, setIsChangingCategory] = useState(false); const [isEditingName, setIsEditingName] = useState(false); const [editingName, setEditingName] = useState(group.name); - + const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color const headerTextColor = getContrastColor(headerBackgroundColor); @@ -85,53 +104,79 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o // If we're grouping by status, show progress based on task completion if (currentGrouping === 'status') { // For status grouping, calculate based on task progress values - const progressStats = tasksInCurrentGroup.reduce((acc, task) => { - const progress = task.progress || 0; - if (progress === 0) { - acc.todo += 1; - } else if (progress === 100) { - acc.done += 1; - } else { - acc.doing += 1; - } - return acc; - }, { todo: 0, doing: 0, done: 0 }); + const progressStats = tasksInCurrentGroup.reduce( + (acc, task) => { + const progress = task.progress || 0; + if (progress === 0) { + acc.todo += 1; + } else if (progress === 100) { + acc.done += 1; + } else { + acc.doing += 1; + } + return acc; + }, + { todo: 0, doing: 0, done: 0 } + ); const totalTasks = tasksInCurrentGroup.length; - + return { - todoProgress: totalTasks > 0 ? Math.round((progressStats.todo / totalTasks) * 100) : 0, - doingProgress: totalTasks > 0 ? Math.round((progressStats.doing / totalTasks) * 100) : 0, - doneProgress: totalTasks > 0 ? Math.round((progressStats.done / totalTasks) * 100) : 0, + todoProgress: totalTasks > 0 ? Math.round((progressStats.todo / totalTasks) * 100) || 0 : 0, + doingProgress: + totalTasks > 0 ? Math.round((progressStats.doing / totalTasks) * 100) || 0 : 0, + doneProgress: totalTasks > 0 ? Math.round((progressStats.done / totalTasks) * 100) || 0 : 0, }; } else { // For priority/phase grouping, show progress based on status distribution // Use a simplified approach based on status names and common patterns - const statusCounts = tasksInCurrentGroup.reduce((acc, task) => { - // Find the status by ID first - const statusInfo = statusList.find(s => s.id === task.status); - const statusName = statusInfo?.name?.toLowerCase() || task.status?.toLowerCase() || ''; - - // Categorize based on common status name patterns - if (statusName.includes('todo') || statusName.includes('to do') || statusName.includes('pending') || statusName.includes('open') || statusName.includes('backlog')) { - acc.todo += 1; - } else if (statusName.includes('doing') || statusName.includes('progress') || statusName.includes('active') || statusName.includes('working') || statusName.includes('development')) { - acc.doing += 1; - } else if (statusName.includes('done') || statusName.includes('completed') || statusName.includes('finished') || statusName.includes('closed') || statusName.includes('resolved')) { - acc.done += 1; - } else { - // Default unknown statuses to "doing" (in progress) - acc.doing += 1; - } - return acc; - }, { todo: 0, doing: 0, done: 0 }); + const statusCounts = tasksInCurrentGroup.reduce( + (acc, task) => { + // Find the status by ID first + const statusInfo = statusList.find(s => s.id === task.status); + const statusName = statusInfo?.name?.toLowerCase() || task.status?.toLowerCase() || ''; + + // Categorize based on common status name patterns + if ( + statusName.includes('todo') || + statusName.includes('to do') || + statusName.includes('pending') || + statusName.includes('open') || + statusName.includes('backlog') + ) { + acc.todo += 1; + } else if ( + statusName.includes('doing') || + statusName.includes('progress') || + statusName.includes('active') || + statusName.includes('working') || + statusName.includes('development') + ) { + acc.doing += 1; + } else if ( + statusName.includes('done') || + statusName.includes('completed') || + statusName.includes('finished') || + statusName.includes('closed') || + statusName.includes('resolved') + ) { + acc.done += 1; + } else { + // Default unknown statuses to "doing" (in progress) + acc.doing += 1; + } + return acc; + }, + { todo: 0, doing: 0, done: 0 } + ); const totalTasks = tasksInCurrentGroup.length; - + return { - todoProgress: totalTasks > 0 ? Math.round((statusCounts.todo / totalTasks) * 100) : 0, - doingProgress: totalTasks > 0 ? Math.round((statusCounts.doing / totalTasks) * 100) : 0, - doneProgress: totalTasks > 0 ? Math.round((statusCounts.done / totalTasks) * 100) : 0, + todoProgress: totalTasks > 0 ? Math.round((statusCounts.todo / totalTasks) * 100) || 0 : 0, + doingProgress: + totalTasks > 0 ? Math.round((statusCounts.doing / totalTasks) * 100) || 0 : 0, + doneProgress: totalTasks > 0 ? Math.round((statusCounts.done / totalTasks) * 100) || 0 : 0, }; } }, [currentGroup, allTasks, statusList, currentGrouping]); @@ -141,30 +186,34 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o if (tasksInGroup.length === 0) { return { isAllSelected: false, isPartiallySelected: false }; } - + const selectedTasksInGroup = tasksInGroup.filter(taskId => selectedTaskIds.includes(taskId)); const allSelected = selectedTasksInGroup.length === tasksInGroup.length; - const partiallySelected = selectedTasksInGroup.length > 0 && selectedTasksInGroup.length < tasksInGroup.length; - + const partiallySelected = + selectedTasksInGroup.length > 0 && selectedTasksInGroup.length < tasksInGroup.length; + return { isAllSelected: allSelected, isPartiallySelected: partiallySelected }; }, [tasksInGroup, selectedTaskIds]); // Handle select all checkbox change - const handleSelectAllChange = useCallback((e: any) => { - e.stopPropagation(); - - if (isAllSelected) { - // Deselect all tasks in this group - tasksInGroup.forEach(taskId => { - dispatch(deselectTask(taskId)); - }); - } else { - // Select all tasks in this group - tasksInGroup.forEach(taskId => { - dispatch(selectTask(taskId)); - }); - } - }, [dispatch, isAllSelected, tasksInGroup]); + const handleSelectAllChange = useCallback( + (e: any) => { + e.stopPropagation(); + + if (isAllSelected) { + // Deselect all tasks in this group + tasksInGroup.forEach(taskId => { + dispatch(deselectTask(taskId)); + }); + } else { + // Select all tasks in this group + tasksInGroup.forEach(taskId => { + dispatch(selectTask(taskId)); + }); + } + }, + [dispatch, isAllSelected, tasksInGroup] + ); // Handle inline name editing const handleNameSave = useCallback(async () => { @@ -184,24 +233,22 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o name: editingName.trim(), project_id: projectId, }; - + await statusApiService.updateNameOfStatus(statusId, body, projectId); trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Status' }); dispatch(fetchStatuses(projectId)); - } else if (currentGrouping === 'phase') { // Extract phase ID from group ID (format: "phase-{phaseId}") const phaseId = group.id.replace('phase-', ''); const body = { id: phaseId, name: editingName.trim() }; - + await phasesApiService.updateNameOfPhase(phaseId, body as ITaskPhase, projectId); trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' }); dispatch(fetchPhasesByProjectId(projectId)); } - + // Refresh task list to get updated group names dispatch(fetchTasksV3(projectId)); - } catch (error) { logger.error('Error renaming group:', error); setEditingName(group.name); @@ -209,24 +256,39 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o setIsEditingName(false); setIsRenaming(false); } - }, [editingName, group.name, group.id, currentGrouping, projectId, dispatch, trackMixpanelEvent, isRenaming]); + }, [ + editingName, + group.name, + group.id, + currentGrouping, + projectId, + dispatch, + trackMixpanelEvent, + isRenaming, + ]); - const handleNameClick = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - if (!isOwnerOrAdmin) return; - setIsEditingName(true); - setEditingName(group.name); - }, [group.name, isOwnerOrAdmin]); - - const handleNameKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleNameSave(); - } else if (e.key === 'Escape') { - setIsEditingName(false); + const handleNameClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (!isOwnerOrAdmin) return; + setIsEditingName(true); setEditingName(group.name); - } - e.stopPropagation(); - }, [group.name, handleNameSave]); + }, + [group.name, isOwnerOrAdmin] + ); + + const handleNameKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleNameSave(); + } else if (e.key === 'Escape') { + setIsEditingName(false); + setEditingName(group.name); + } + e.stopPropagation(); + }, + [group.name, handleNameSave] + ); const handleNameBlur = useCallback(() => { handleNameSave(); @@ -239,31 +301,31 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o setEditingName(group.name); }, [group.name]); - - // Handle category change - const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => { - e?.stopPropagation(); - if (isChangingCategory) return; + const handleCategoryChange = useCallback( + async (categoryId: string, e?: React.MouseEvent) => { + e?.stopPropagation(); + if (isChangingCategory) return; - setIsChangingCategory(true); - try { - // Extract status ID from group ID (format: "status-{statusId}") - const statusId = group.id.replace('status-', ''); - - await statusApiService.updateStatusCategory(statusId, categoryId, projectId); - trackMixpanelEvent(evt_project_board_column_setting_click, { 'Change category': 'Status' }); - - // Refresh status list and tasks - dispatch(fetchStatuses(projectId)); - dispatch(fetchTasksV3(projectId)); - - } catch (error) { - logger.error('Error changing category:', error); - } finally { - setIsChangingCategory(false); - } - }, [group.id, projectId, dispatch, trackMixpanelEvent, isChangingCategory]); + setIsChangingCategory(true); + try { + // Extract status ID from group ID (format: "status-{statusId}") + const statusId = group.id.replace('status-', ''); + + await statusApiService.updateStatusCategory(statusId, categoryId, projectId); + trackMixpanelEvent(evt_project_board_column_setting_click, { 'Change category': 'Status' }); + + // Refresh status list and tasks + dispatch(fetchStatuses(projectId)); + dispatch(fetchTasksV3(projectId)); + } catch (error) { + logger.error('Error changing category:', error); + } finally { + setIsChangingCategory(false); + } + }, + [group.id, projectId, dispatch, trackMixpanelEvent, isChangingCategory] + ); // Create dropdown menu items const menuItems = useMemo(() => { @@ -273,7 +335,12 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o { key: 'rename', icon: , - label: currentGrouping === 'status' ? t('renameStatus') : currentGrouping === 'phase' ? t('renamePhase') : t('renameGroup'), + label: + currentGrouping === 'status' + ? t('renameStatus') + : currentGrouping === 'phase' + ? t('renamePhase') + : t('renameGroup'), onClick: (e: any) => { e?.domEvent?.stopPropagation(); handleRenameGroup(); @@ -283,7 +350,7 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o // Only show "Change Category" when grouped by status if (currentGrouping === 'status') { - const categorySubMenuItems = statusCategories.map((category) => ({ + const categorySubMenuItems = statusCategories.map(category => ({ key: `category-${category.id}`, label: (
@@ -297,16 +364,23 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o }, })); - items.push({ - key: 'changeCategory', - icon: , - label: t('changeCategory'), - children: categorySubMenuItems, - } as any); + items.push({ + key: 'changeCategory', + icon: , + label: t('changeCategory'), + children: categorySubMenuItems, + } as any); } return items; - }, [currentGrouping, handleRenameGroup, handleCategoryChange, isOwnerOrAdmin, statusCategories, t]); + }, [ + currentGrouping, + handleRenameGroup, + handleCategoryChange, + isOwnerOrAdmin, + statusCategories, + t, + ]); // Make the group header droppable const { isOver, setNodeRef } = useDroppable({ @@ -317,7 +391,7 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o }, }); - return ( + return (
= ({ group, isCollapsed, o zIndex: 25, // Higher than task rows but lower than column headers (z-30) height: '36px', minHeight: '36px', - maxHeight: '36px' + maxHeight: '36px', }} onClick={onToggle} > {/* Drag Handle Space - ultra minimal width */}
{/* Chevron button */} - + +
+ )}
- {/* Three-dot menu - only show for status and phase grouping */} - {menuItems.length > 0 && (currentGrouping === 'status' || currentGrouping === 'phase') && ( -
- - - -
- )} - -
- {/* Progress Bar - sticky to the right edge during horizontal scroll */} - {(currentGrouping === 'priority' || currentGrouping === 'phase') && - (groupProgressValues.todoProgress || groupProgressValues.doingProgress || groupProgressValues.doneProgress) && ( -
- -
- )} + {(currentGrouping === 'priority' || currentGrouping === 'phase') && + !(groupProgressValues.todoProgress === 0 && groupProgressValues.doingProgress === 0 && groupProgressValues.doneProgress === 0) && ( +
+ +
+ )}
); }; -export default TaskGroupHeader; \ No newline at end of file +export default TaskGroupHeader; diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index 1cc6c680..4991cc3b 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -60,13 +60,13 @@ import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/ // Components import TaskRowWithSubtasks from './TaskRowWithSubtasks'; import TaskGroupHeader from './TaskGroupHeader'; -import ImprovedTaskFilters from '@/components/task-management/improved-task-filters'; import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar'; import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal'; import AddTaskRow from './components/AddTaskRow'; import { AddCustomColumnButton, CustomColumnHeader } from './components/CustomColumnComponents'; import TaskListSkeleton from './components/TaskListSkeleton'; import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer'; +import EmptyListPlaceholder from '@/components/EmptyListPlaceholder'; // Empty Group Drop Zone Component const EmptyGroupDropZone: React.FC<{ @@ -90,12 +90,28 @@ const EmptyGroupDropZone: React.FC<{ isOver && active ? 'bg-blue-50 dark:bg-blue-900/20' : '' }`} > -
+
{visibleColumns.map((column, index) => { const emptyColumnStyle = { width: column.width, flexShrink: 0, }; + + // Show text in the title column + if (column.id === 'title') { + return ( +
+ + No tasks in this group + +
+ ); + } + return (
-
-
- {isOver && active ? t('dropTaskHere') || 'Drop task here' : t('noTasksInGroup')} -
-
{isOver && active && (
)} @@ -179,6 +184,8 @@ const TaskListV2Section: React.FC = () => { const { projectId: urlProjectId } = useParams(); const { t } = useTranslation('task-list-table'); const { socket, connected } = useSocket(); + const themeMode = useAppSelector(state => state.themeReducer.mode); + const isDarkMode = themeMode === 'dark'; // Redux state selectors const allTasks = useAppSelector(selectAllTasksArray); @@ -492,7 +499,7 @@ const TaskListV2Section: React.FC = () => { isAddTaskRow: true, groupId: group.id, groupType: currentGrouping || 'status', - groupValue: group.id, // Use the actual database ID from backend + groupValue: group.id, // Send the UUID that backend expects projectId: urlProjectId, rowId: `add-task-${group.id}-0`, autoFocus: false, @@ -503,7 +510,7 @@ const TaskListV2Section: React.FC = () => { isAddTaskRow: true, groupId: group.id, groupType: currentGrouping || 'status', - groupValue: group.id, + groupValue: group.id, // Send the UUID that backend expects projectId: urlProjectId, rowId: rowId, autoFocus: index === groupAddRows.length - 1, // Auto-focus the latest row @@ -536,6 +543,7 @@ const TaskListV2Section: React.FC = () => { return virtuosoGroups.flatMap(group => group.tasks); }, [virtuosoGroups]); + // Render functions const renderGroup = useCallback( (groupIndex: number) => { @@ -550,11 +558,7 @@ const TaskListV2Section: React.FC = () => { id: group.id, name: group.title, count: group.actualCount, - color: group.color, - todo_progress: group.todo_progress, - doing_progress: group.doing_progress, - done_progress: group.done_progress, - groupType: group.groupType, + color: isDarkMode ? group.color_code_dark : group.color, }} isCollapsed={isGroupCollapsed} onToggle={() => handleGroupCollapse(group.id)} @@ -685,13 +689,94 @@ const TaskListV2Section: React.FC = () => {
); - // Show message when no data + // Show message when no data - but for phase grouping, create an unmapped group if (groups.length === 0 && !loading) { + // If grouped by phase, show an unmapped group to allow task creation + if (currentGrouping === 'phase') { + const unmappedGroup = { + id: 'Unmapped', + title: 'Unmapped', + groupType: 'phase', + groupValue: 'Unmapped', // Use same ID as groupValue for consistency + collapsed: false, + tasks: [], + taskIds: [], + color: '#fbc84c69', + actualCount: 0, + count: 1, // For the add task row + startIndex: 0 + }; + + return ( + +
+
+
+ {/* Sticky Column Headers */} +
+ {renderColumnHeaders()} +
+ +
+
+ {}} + projectId={urlProjectId || ''} + /> + +
+
+
+
+
+
+ ); + } + + // For other groupings, show the empty state message return (
-
- -
@@ -812,19 +897,17 @@ const TaskListV2Section: React.FC = () => { {/* Drag Overlay */} {activeId ? ( -
-
-
- -
-
- {allTasks.find(task => task.id === activeId)?.name || - allTasks.find(task => task.id === activeId)?.title || - t('emptyStates.dragTaskFallback')} -
-
- {allTasks.find(task => task.id === activeId)?.task_key} -
+
col.id === 'title')?.width || '300px' }} + > +
+
+ +
+ {allTasks.find(task => task.id === activeId)?.name || + allTasks.find(task => task.id === activeId)?.title || + t('emptyStates.dragTaskFallback')}
diff --git a/worklenz-frontend/src/components/task-list-v2/components/AddTaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/components/AddTaskRow.tsx index 57806e77..43dfa7f5 100644 --- a/worklenz-frontend/src/components/task-list-v2/components/AddTaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/components/AddTaskRow.tsx @@ -125,9 +125,9 @@ const AddTaskRow: React.FC = memo(({ return
; case 'title': return ( -
+
-
+
{!isAdding ? ( {t('orText')} @@ -146,7 +146,7 @@ const ForgotPasswordPage = () => { borderRadius: 4, }} > - {t('returnToLoginButton')} + {t('returnToLoginButton', {defaultValue: 'Return to Login'})} diff --git a/worklenz-frontend/src/pages/auth/logging-out.tsx b/worklenz-frontend/src/pages/auth/LoggingOutPage.tsx similarity index 100% rename from worklenz-frontend/src/pages/auth/logging-out.tsx rename to worklenz-frontend/src/pages/auth/LoggingOutPage.tsx diff --git a/worklenz-frontend/src/pages/auth/login-page.tsx b/worklenz-frontend/src/pages/auth/LoginPage.tsx similarity index 100% rename from worklenz-frontend/src/pages/auth/login-page.tsx rename to worklenz-frontend/src/pages/auth/LoginPage.tsx diff --git a/worklenz-frontend/src/pages/auth/signup-page.tsx b/worklenz-frontend/src/pages/auth/SignupPage.tsx similarity index 76% rename from worklenz-frontend/src/pages/auth/signup-page.tsx rename to worklenz-frontend/src/pages/auth/SignupPage.tsx index 68d3f9e7..7f096da2 100644 --- a/worklenz-frontend/src/pages/auth/signup-page.tsx +++ b/worklenz-frontend/src/pages/auth/SignupPage.tsx @@ -5,6 +5,8 @@ import { useMediaQuery } from 'react-responsive'; import { LockOutlined, MailOutlined, UserOutlined } from '@ant-design/icons'; import { Form, Card, Input, Flex, Button, Typography, Space, message } from 'antd/es'; import { Rule } from 'antd/es/form'; +import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons'; +import { useAppSelector } from '@/hooks/useAppSelector'; import googleIcon from '@/assets/images/google-icon.png'; import PageHeader from '@components/AuthPageHeader'; @@ -297,6 +299,10 @@ const SignupPage = () => { min: 8, message: t('passwordMinCharacterRequired'), }, + { + max: 32, + message: t('passwordMaxCharacterRequired'), + }, { pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])/, message: t('passwordPatternRequired'), @@ -304,6 +310,38 @@ const SignupPage = () => { ], }; + const passwordChecklistItems = [ + { + key: 'minLength', + test: (v: string) => v.length >= 8, + label: t('passwordChecklist.minLength', { defaultValue: 'At least 8 characters' }), + }, + { + key: 'uppercase', + test: (v: string) => /[A-Z]/.test(v), + label: t('passwordChecklist.uppercase', { defaultValue: 'One uppercase letter' }), + }, + { + key: 'lowercase', + test: (v: string) => /[a-z]/.test(v), + label: t('passwordChecklist.lowercase', { defaultValue: 'One lowercase letter' }), + }, + { + key: 'number', + test: (v: string) => /\d/.test(v), + label: t('passwordChecklist.number', { defaultValue: 'One number' }), + }, + { + key: 'special', + test: (v: string) => /[@$!%*?&#]/.test(v), + label: t('passwordChecklist.special', { defaultValue: 'One special character' }), + }, + ]; + + const themeMode = useAppSelector(state => state.themeReducer.mode); + const [passwordValue, setPasswordValue] = useState(''); + const [passwordActive, setPasswordActive] = useState(false); + return ( { }} variant="outlined" > - +
{ name: urlParams.name, }} > - + } - placeholder={t('namePlaceholder')} + placeholder={t('namePlaceholder', {defaultValue: 'Enter your full name'})} size="large" style={{ borderRadius: 4 }} /> - + } - placeholder={t('emailPlaceholder')} + placeholder={t('emailPlaceholder', {defaultValue: 'Enter your email'})} size="large" style={{ borderRadius: 4 }} /> - +
} - placeholder={t('strongPasswordPlaceholder')} + placeholder={t('strongPasswordPlaceholder', {defaultValue: 'Enter a strong password'})} size="large" style={{ borderRadius: 4 }} + value={passwordValue} + onFocus={() => setPasswordActive(true)} + onChange={e => { + setPasswordValue(e.target.value); + setPasswordActive(true); + }} + onBlur={() => { + if (!passwordValue) setPasswordActive(false); + }} /> - - {t('passwordValidationAltText')} + + {t('passwordGuideline', { + defaultValue: 'Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.' + })} + {passwordActive && ( +
+ {passwordChecklistItems.map(item => { + const passed = item.test(passwordValue); + // Only green if passed, otherwise neutral (never red) + let color = passed + ? (themeMode === 'dark' ? '#52c41a' : '#389e0d') + : (themeMode === 'dark' ? '#b0b3b8' : '#bfbfbf'); + return ( + + {passed ? ( + + ) : ( + + )} + {item.label} + + ); + })} +
+ )}
@@ -416,7 +491,7 @@ const SignupPage = () => { - {t('alreadyHaveAccountText')} + {t('alreadyHaveAccountText', {defaultValue: 'Already have an account?'})} { const { t } = useTranslation('auth/verify-reset-email'); const isMobile = useMediaQuery({ query: '(max-width: 576px)' }); + const themeMode = useAppSelector(state => state.themeReducer.mode); + const [passwordValue, setPasswordValue] = useState(''); + const [passwordTouched, setPasswordTouched] = useState(false); + const passwordChecklistItems = [ + { + key: 'minLength', + test: (v: string) => v.length >= 8, + label: t('passwordChecklist.minLength', { defaultValue: 'At least 8 characters' }), + }, + { + key: 'uppercase', + test: (v: string) => /[A-Z]/.test(v), + label: t('passwordChecklist.uppercase', { defaultValue: 'One uppercase letter' }), + }, + { + key: 'lowercase', + test: (v: string) => /[a-z]/.test(v), + label: t('passwordChecklist.lowercase', { defaultValue: 'One lowercase letter' }), + }, + { + key: 'number', + test: (v: string) => /\d/.test(v), + label: t('passwordChecklist.number', { defaultValue: 'One number' }), + }, + { + key: 'special', + test: (v: string) => /[@$!%*?&#]/.test(v), + label: t('passwordChecklist.special', { defaultValue: 'One special character' }), + }, + ]; useEffect(() => { trackMixpanelEvent(evt_verify_reset_email_page_visit); @@ -104,12 +136,38 @@ const VerifyResetEmailPage = () => { }, ]} > - } - placeholder={t('placeholder')} - size="large" - style={{ borderRadius: 4 }} - /> +
+ } + placeholder={t('placeholder')} + size="large" + style={{ borderRadius: 4 }} + value={passwordValue} + onChange={e => { + setPasswordValue(e.target.value); + if (!passwordTouched) setPasswordTouched(true); + }} + onBlur={() => setPasswordTouched(true)} + /> +
+ {passwordChecklistItems.map(item => { + const passed = item.test(passwordValue); + let color = passed + ? (themeMode === 'dark' ? '#52c41a' : '#389e0d') + : (themeMode === 'dark' ? '#b0b3b8' : '#bfbfbf'); + return ( + + {passed ? ( + + ) : ( + + )} + {item.label} + + ); + })} +
+
{ placeholder={t('confirmPasswordPlaceholder')} size="large" style={{ borderRadius: 4 }} + value={form.getFieldValue('confirmPassword') || ''} + onChange={e => form.setFieldsValue({ confirmPassword: e.target.value })} /> diff --git a/worklenz-frontend/src/pages/home/task-list/tasks-list.css b/worklenz-frontend/src/pages/home/task-list/tasks-list.css index 698cfcdc..bb9c82ce 100644 --- a/worklenz-frontend/src/pages/home/task-list/tasks-list.css +++ b/worklenz-frontend/src/pages/home/task-list/tasks-list.css @@ -6,3 +6,81 @@ .ant-table-row:hover .row-action-button { opacity: 1; } + +/* Responsive styles for task list */ +@media (max-width: 768px) { + .task-list-card .ant-card-head { + flex-direction: column; + gap: 12px; + } + + .task-list-card .ant-card-head-title { + flex: 1; + width: 100%; + } + + .task-list-card .ant-card-extra { + width: 100%; + justify-content: space-between; + } + + .task-list-mobile-header { + flex-direction: column; + gap: 8px; + align-items: stretch !important; + } + + .task-list-mobile-controls { + flex-direction: column; + gap: 8px; + align-items: stretch !important; + } + + .task-list-mobile-select { + width: 100% !important; + } + + .task-list-mobile-segmented { + width: 100% !important; + } +} + +@media (max-width: 576px) { + .task-list-card .ant-table { + font-size: 12px; + } + + .task-list-card .ant-table-thead > tr > th { + padding: 8px 4px; + font-size: 12px; + } + + .task-list-card .ant-table-tbody > tr > td { + padding: 8px 4px; + } + + .row-action-button { + opacity: 1; /* Always show on mobile */ + } + + /* Hide project column on very small screens */ + .task-list-card .ant-table-thead > tr > th:nth-child(2), + .task-list-card .ant-table-tbody > tr > td:nth-child(2) { + display: none; + } +} + +/* Table responsive container */ +.task-list-card .ant-table-container { + overflow-x: auto; +} + +@media (max-width: 768px) { + .task-list-card .ant-table-wrapper { + overflow-x: auto; + } + + .task-list-card .ant-table { + min-width: 600px; + } +} diff --git a/worklenz-frontend/src/pages/home/task-list/tasks-list.tsx b/worklenz-frontend/src/pages/home/task-list/tasks-list.tsx index 01e7706c..17ba17dd 100644 --- a/worklenz-frontend/src/pages/home/task-list/tasks-list.tsx +++ b/worklenz-frontend/src/pages/home/task-list/tasks-list.tsx @@ -15,6 +15,7 @@ import { } from 'antd'; import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { useMediaQuery } from 'react-responsive'; import ListView from './list-view'; import CalendarView from './calendar-view'; @@ -61,21 +62,22 @@ const TasksList: React.FC = React.memo(() => { refetchOnFocus: false, }); - const { t } = useTranslation('home'); + const { t, ready } = useTranslation('home'); const { model } = useAppSelector(state => state.homePageReducer); + const isMobile = useMediaQuery({ maxWidth: 768 }); const taskModes = useMemo( () => [ { value: 0, - label: t('home:tasks.assignedToMe'), + label: ready ? t('tasks.assignedToMe') : 'Assigned to me', }, { value: 1, - label: t('home:tasks.assignedByMe'), + label: ready ? t('tasks.assignedByMe') : 'Assigned by me', }, ], - [t] + [t, ready] ); const handleSegmentChange = (value: 'List' | 'Calendar') => { @@ -123,7 +125,7 @@ const TasksList: React.FC = React.memo(() => { {t('tasks.name')} ), - width: '40%', + width: isMobile ? '50%' : '40%', render: (_, record) => (
@@ -155,7 +157,7 @@ const TasksList: React.FC = React.memo(() => { { key: 'project', title: t('tasks.project'), - width: '25%', + width: isMobile ? '30%' : '25%', render: (_, record) => { return ( @@ -185,7 +187,7 @@ const TasksList: React.FC = React.memo(() => { render: (_, record) => , }, ], - [t, data?.body?.total, currentPage, pageSize, handlePageChange] + [t, data?.body?.total, currentPage, pageSize, handlePageChange, isMobile] ); const handleTaskModeChange = (value: number) => { @@ -210,23 +212,27 @@ const TasksList: React.FC = React.memo(() => { ); }, [dispatch]); + return ( + {t('tasks.tasks')}