diff --git a/worklenz-backend/database/migrations/20250422132400-manual-task-progress.sql b/worklenz-backend/database/migrations/20250422132400-manual-task-progress.sql new file mode 100644 index 00000000..3eea7a2b --- /dev/null +++ b/worklenz-backend/database/migrations/20250422132400-manual-task-progress.sql @@ -0,0 +1,77 @@ +-- Migration: Add manual task progress +-- Date: 2025-04-22 +-- Version: 1.0.0 + +BEGIN; + +-- Add manual progress fields to tasks table +ALTER TABLE tasks +ADD COLUMN IF NOT EXISTS manual_progress BOOLEAN DEFAULT FALSE, +ADD COLUMN IF NOT EXISTS progress_value INTEGER DEFAULT NULL; + +-- Update function to consider manual progress +CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _parent_task_done FLOAT = 0; + _sub_tasks_done FLOAT = 0; + _sub_tasks_count FLOAT = 0; + _total_completed FLOAT = 0; + _total_tasks FLOAT = 0; + _ratio FLOAT = 0; + _is_manual BOOLEAN = FALSE; + _manual_value INTEGER = NULL; +BEGIN + -- Check if manual progress is set + SELECT manual_progress, progress_value + FROM tasks + WHERE id = _task_id + INTO _is_manual, _manual_value; + + -- If manual progress is enabled and has a value, use it directly + IF _is_manual IS TRUE AND _manual_value IS NOT NULL THEN + RETURN JSON_BUILD_OBJECT( + 'ratio', _manual_value, + 'total_completed', 0, + 'total_tasks', 0, + 'is_manual', TRUE + ); + END IF; + + -- Otherwise calculate automatically as before + SELECT (CASE + WHEN EXISTS(SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE) THEN 1 + ELSE 0 END) + INTO _parent_task_done; + SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id AND archived IS FALSE INTO _sub_tasks_count; + + SELECT COUNT(*) + FROM tasks_with_status_view + WHERE parent_task_id = _task_id + AND is_done IS TRUE + INTO _sub_tasks_done; + + _total_completed = _parent_task_done + _sub_tasks_done; + _total_tasks = _sub_tasks_count; -- +1 for the parent task + + IF _total_tasks > 0 THEN + _ratio = (_total_completed / _total_tasks) * 100; + ELSE + _ratio = _parent_task_done * 100; + END IF; + + RETURN JSON_BUILD_OBJECT( + 'ratio', _ratio, + 'total_completed', _total_completed, + 'total_tasks', _total_tasks, + 'is_manual', FALSE + ); +END +$$; + +COMMIT; \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql new file mode 100644 index 00000000..022f5061 --- /dev/null +++ b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql @@ -0,0 +1,524 @@ +-- Migration: Enhance manual task progress with subtask support +-- Date: 2025-04-23 +-- Version: 1.0.0 + +BEGIN; + +-- Update function to consider subtask manual progress when calculating parent task progress +CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _parent_task_done FLOAT = 0; + _sub_tasks_done FLOAT = 0; + _sub_tasks_count FLOAT = 0; + _total_completed FLOAT = 0; + _total_tasks FLOAT = 0; + _ratio FLOAT = 0; + _is_manual BOOLEAN = FALSE; + _manual_value INTEGER = NULL; + _project_id UUID; + _use_manual_progress BOOLEAN = FALSE; + _use_weighted_progress BOOLEAN = FALSE; + _use_time_progress BOOLEAN = FALSE; +BEGIN + -- Check if manual progress is set for this task + SELECT manual_progress, progress_value, project_id + FROM tasks + WHERE id = _task_id + INTO _is_manual, _manual_value, _project_id; + + -- Check if the project uses manual progress + IF _project_id IS NOT NULL THEN + SELECT COALESCE(use_manual_progress, FALSE), + COALESCE(use_weighted_progress, FALSE), + COALESCE(use_time_progress, FALSE) + FROM projects + WHERE id = _project_id + INTO _use_manual_progress, _use_weighted_progress, _use_time_progress; + END IF; + + -- If manual progress is enabled and has a value, use it directly + IF _is_manual IS TRUE AND _manual_value IS NOT NULL THEN + RETURN JSON_BUILD_OBJECT( + 'ratio', _manual_value, + 'total_completed', 0, + 'total_tasks', 0, + 'is_manual', TRUE + ); + END IF; + + -- Get all subtasks + SELECT COUNT(*) + FROM tasks + WHERE parent_task_id = _task_id AND archived IS FALSE + INTO _sub_tasks_count; + + -- If there are no subtasks, just use the parent task's status + IF _sub_tasks_count = 0 THEN + SELECT (CASE + WHEN EXISTS(SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE) THEN 1 + ELSE 0 END) + INTO _parent_task_done; + + _ratio = _parent_task_done * 100; + ELSE + -- If project uses manual progress, calculate based on subtask manual progress values + IF _use_manual_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + CASE + -- If subtask has manual progress, use that value + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + -- Otherwise use completion status (0 or 100) + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE(AVG(progress_value), 0) + FROM subtask_progress + INTO _ratio; + -- If project uses weighted progress, calculate based on subtask weights + ELSIF _use_weighted_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + CASE + -- If subtask has manual progress, use that value + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + -- Otherwise use completion status (0 or 100) + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value, + COALESCE(weight, 100) AS weight + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE( + SUM(progress_value * weight) / NULLIF(SUM(weight), 0), + 0 + ) + FROM subtask_progress + INTO _ratio; + -- If project uses time-based progress, calculate based on estimated time + ELSIF _use_time_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + CASE + -- If subtask has manual progress, use that value + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + -- Otherwise use completion status (0 or 100) + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value, + COALESCE(total_hours * 60 + total_minutes, 0) AS estimated_minutes + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE( + SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0), + 0 + ) + FROM subtask_progress + INTO _ratio; + ELSE + -- Traditional calculation based on completion status + SELECT (CASE + WHEN EXISTS(SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE) THEN 1 + ELSE 0 END) + INTO _parent_task_done; + + SELECT COUNT(*) + FROM tasks_with_status_view + WHERE parent_task_id = _task_id + AND is_done IS TRUE + INTO _sub_tasks_done; + + _total_completed = _parent_task_done + _sub_tasks_done; + _total_tasks = _sub_tasks_count + 1; -- +1 for the parent task + + IF _total_tasks = 0 THEN + _ratio = 0; + ELSE + _ratio = (_total_completed / _total_tasks) * 100; + END IF; + END IF; + END IF; + + -- Ensure ratio is between 0 and 100 + IF _ratio < 0 THEN + _ratio = 0; + ELSIF _ratio > 100 THEN + _ratio = 100; + END IF; + + RETURN JSON_BUILD_OBJECT( + 'ratio', _ratio, + 'total_completed', _total_completed, + 'total_tasks', _total_tasks, + 'is_manual', _is_manual + ); +END +$$; + +CREATE OR REPLACE FUNCTION update_project(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _user_id UUID; + _team_id UUID; + _client_id UUID; + _project_id UUID; + _project_manager_team_member_id UUID; + _client_name TEXT; + _project_name TEXT; +BEGIN + -- need a test, can be throw errors + _client_name = TRIM((_body ->> 'client_name')::TEXT); + _project_name = TRIM((_body ->> 'name')::TEXT); + + -- add inside the controller + _user_id = (_body ->> 'user_id')::UUID; + _team_id = (_body ->> 'team_id')::UUID; + _project_manager_team_member_id = (_body ->> 'team_member_id')::UUID; + + -- cache exists client if exists + SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id; + + -- insert client if not exists + IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE + THEN + INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id; + END IF; + + -- check whether the project name is already in + IF EXISTS( + SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name) + AND team_id = _team_id AND id != (_body ->> 'id')::UUID + ) + THEN + RAISE 'PROJECT_EXISTS_ERROR:%', _project_name; + END IF; + + -- update the project + UPDATE projects + SET name = _project_name, + notes = (_body ->> 'notes')::TEXT, + color_code = (_body ->> 'color_code')::TEXT, + status_id = (_body ->> 'status_id')::UUID, + health_id = (_body ->> 'health_id')::UUID, + key = (_body ->> 'key')::TEXT, + start_date = (_body ->> 'start_date')::TIMESTAMPTZ, + end_date = (_body ->> 'end_date')::TIMESTAMPTZ, + client_id = _client_id, + folder_id = (_body ->> 'folder_id')::UUID, + category_id = (_body ->> 'category_id')::UUID, + updated_at = CURRENT_TIMESTAMP, + estimated_working_days = (_body ->> 'working_days')::INTEGER, + estimated_man_days = (_body ->> 'man_days')::INTEGER, + hours_per_day = (_body ->> 'hours_per_day')::INTEGER, + use_manual_progress = COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE), + use_weighted_progress = COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE), + use_time_progress = COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE) + WHERE id = (_body ->> 'id')::UUID + AND team_id = _team_id + RETURNING id INTO _project_id; + + UPDATE project_members SET project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'MEMBER') WHERE project_id = _project_id; + + IF NOT (_project_manager_team_member_id IS NULL) + THEN + PERFORM update_project_manager(_project_manager_team_member_id, _project_id::UUID); + END IF; + + RETURN JSON_BUILD_OBJECT( + 'id', _project_id, + 'name', (_body ->> 'name')::TEXT, + 'project_manager_id', _project_manager_team_member_id::UUID + ); +END; +$$; + +-- 3. Also modify the create_project function to handle the new fields during project creation +CREATE OR REPLACE FUNCTION create_project(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _project_id UUID; + _user_id UUID; + _team_id UUID; + _team_member_id UUID; + _client_id UUID; + _client_name TEXT; + _project_name TEXT; + _project_created_log TEXT; + _project_member_added_log TEXT; + _project_created_log_id UUID; + _project_manager_team_member_id UUID; + _project_key TEXT; +BEGIN + _client_name = TRIM((_body ->> 'client_name')::TEXT); + _project_name = TRIM((_body ->> 'name')::TEXT); + _project_key = TRIM((_body ->> 'key')::TEXT); + _project_created_log = (_body ->> 'project_created_log')::TEXT; + _project_member_added_log = (_body ->> 'project_member_added_log')::TEXT; + _user_id = (_body ->> 'user_id')::UUID; + _team_id = (_body ->> 'team_id')::UUID; + _project_manager_team_member_id = (_body ->> 'project_manager_id')::UUID; + + SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id INTO _team_member_id; + + -- cache exists client if exists + SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id; + + -- insert client if not exists + IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE + THEN + INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id; + END IF; + + -- check whether the project name is already in + IF EXISTS(SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name) AND team_id = _team_id) + THEN + RAISE 'PROJECT_EXISTS_ERROR:%', _project_name; + END IF; + + -- create the project + INSERT + INTO projects (name, key, color_code, start_date, end_date, team_id, notes, owner_id, status_id, health_id, folder_id, + category_id, estimated_working_days, estimated_man_days, hours_per_day, + use_manual_progress, use_weighted_progress, use_time_progress, client_id) + VALUES (_project_name, + UPPER(_project_key), + (_body ->> 'color_code')::TEXT, + (_body ->> 'start_date')::TIMESTAMPTZ, + (_body ->> 'end_date')::TIMESTAMPTZ, + _team_id, + (_body ->> 'notes')::TEXT, + _user_id, + (_body ->> 'status_id')::UUID, + (_body ->> 'health_id')::UUID, + (_body ->> 'folder_id')::UUID, + (_body ->> 'category_id')::UUID, + (_body ->> 'working_days')::INTEGER, + (_body ->> 'man_days')::INTEGER, + (_body ->> 'hours_per_day')::INTEGER, + COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE), + COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE), + COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE), + _client_id) + RETURNING id INTO _project_id; + + -- register the project log + INSERT INTO project_logs (project_id, team_id, team_member_id, description) + VALUES (_project_id, _team_id, _team_member_id, _project_created_log) + RETURNING id INTO _project_created_log_id; + + -- add the team member in the project as a user + INSERT INTO project_members (project_id, team_member_id, project_access_level_id) + VALUES (_project_id, _team_member_id, + (SELECT id FROM project_access_levels WHERE key = 'MEMBER')); + + -- register the project log + INSERT INTO project_logs (project_id, team_id, team_member_id, description) + VALUES (_project_id, _team_id, _team_member_id, _project_member_added_log); + + -- insert default project columns + PERFORM insert_task_list_columns(_project_id); + + -- add project manager role if exists + IF NOT is_null_or_empty(_project_manager_team_member_id) THEN + PERFORM update_project_manager(_project_manager_team_member_id, _project_id); + END IF; + + RETURN JSON_BUILD_OBJECT( + 'id', _project_id, + 'name', _project_name, + 'project_created_log_id', _project_created_log_id + ); +END; +$$; + +-- 4. Update the getById function to include the new fields in the response +CREATE OR REPLACE FUNCTION getProjectById(_project_id UUID, _team_id UUID) RETURNS JSON + LANGUAGE plpgsql +AS +$$ +DECLARE + _result JSON; +BEGIN + SELECT ROW_TO_JSON(rec) INTO _result + FROM (SELECT p.id, + p.name, + p.key, + p.color_code, + p.start_date, + p.end_date, + c.name AS client_name, + c.id AS client_id, + p.notes, + p.created_at, + p.updated_at, + ts.name AS status, + ts.color_code AS status_color, + ts.icon AS status_icon, + ts.id AS status_id, + h.name AS health, + h.color_code AS health_color, + h.icon AS health_icon, + h.id AS health_id, + pc.name AS category_name, + pc.color_code AS category_color, + pc.id AS category_id, + p.phase_label, + p.estimated_man_days AS man_days, + p.estimated_working_days AS working_days, + p.hours_per_day, + p.use_manual_progress, + p.use_weighted_progress, + -- Additional fields + COALESCE((SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))) + FROM (SELECT pm.id, + pm.project_id, + tm.id AS team_member_id, + tm.user_id, + u.name, + u.email, + u.avatar_url, + u.phone_number, + pal.name AS access_level, + pal.key AS access_level_key, + pal.id AS access_level_id, + EXISTS(SELECT 1 + FROM project_members + INNER JOIN project_access_levels ON + project_members.project_access_level_id = project_access_levels.id + WHERE project_id = p.id + AND project_access_levels.key = 'PROJECT_MANAGER' + AND team_member_id = tm.id) AS is_project_manager + FROM project_members pm + INNER JOIN team_members tm ON pm.team_member_id = tm.id + INNER JOIN users u ON tm.user_id = u.id + INNER JOIN project_access_levels pal ON pm.project_access_level_id = pal.id + WHERE pm.project_id = p.id) t), '[]'::JSON) AS members, + (SELECT COUNT(DISTINCT (id)) + FROM tasks + WHERE archived IS FALSE + AND project_id = p.id) AS task_count, + (SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))) + FROM (SELECT project_members.id, + project_members.project_id, + team_members.id AS team_member_id, + team_members.user_id, + users.name, + users.email, + users.avatar_url, + project_access_levels.name AS access_level, + project_access_levels.key AS access_level_key, + project_access_levels.id AS access_level_id + FROM project_members + INNER JOIN team_members ON project_members.team_member_id = team_members.id + INNER JOIN users ON team_members.user_id = users.id + INNER JOIN project_access_levels + ON project_members.project_access_level_id = project_access_levels.id + WHERE project_id = p.id + AND project_access_levels.key = 'PROJECT_MANAGER' + LIMIT 1) t) AS project_manager, + + (SELECT EXISTS(SELECT 1 + FROM project_subscribers + WHERE project_id = p.id + AND user_id = (SELECT user_id + FROM project_members + WHERE team_member_id = (SELECT id + FROM team_members + WHERE user_id IN + (SELECT user_id FROM is_member_of_project_cte)) + AND project_id = p.id))) AS subscribed, + (SELECT name + FROM users + WHERE id = + (SELECT owner_id FROM projects WHERE id = p.id)) AS project_owner, + (SELECT default_view + FROM project_members + WHERE project_id = p.id + AND team_member_id IN (SELECT id FROM is_member_of_project_cte)) AS team_member_default_view, + (SELECT EXISTS(SELECT user_id + FROM archived_projects + WHERE user_id IN (SELECT user_id FROM is_member_of_project_cte) + AND project_id = p.id)) AS archived, + + (SELECT EXISTS(SELECT user_id + FROM favorite_projects + WHERE user_id IN (SELECT user_id FROM is_member_of_project_cte) + AND project_id = p.id)) AS favorite + + FROM projects p + LEFT JOIN sys_project_statuses ts ON p.status_id = ts.id + LEFT JOIN sys_project_healths h ON p.health_id = h.id + LEFT JOIN project_categories pc ON p.category_id = pc.id + LEFT JOIN clients c ON p.client_id = c.id, + LATERAL (SELECT id, user_id + FROM team_members + WHERE id = (SELECT team_member_id + FROM project_members + WHERE project_id = p.id + AND team_member_id IN (SELECT id + FROM team_members + WHERE team_id = _team_id) + LIMIT 1)) is_member_of_project_cte + + WHERE p.id = _project_id + AND p.team_id = _team_id) rec; + + RETURN _result; +END +$$; + +-- Add use_manual_progress, use_weighted_progress, and use_time_progress to projects table if they don't exist +ALTER TABLE projects +ADD COLUMN IF NOT EXISTS use_manual_progress BOOLEAN DEFAULT FALSE, +ADD COLUMN IF NOT EXISTS use_weighted_progress BOOLEAN DEFAULT FALSE, +ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE; + +COMMIT; \ No newline at end of file diff --git a/worklenz-backend/src/controllers/projects-controller.ts b/worklenz-backend/src/controllers/projects-controller.ts index e739bfb1..9a2f2d74 100644 --- a/worklenz-backend/src/controllers/projects-controller.ts +++ b/worklenz-backend/src/controllers/projects-controller.ts @@ -408,6 +408,9 @@ export default class ProjectsController extends WorklenzControllerBase { sps.color_code AS status_color, sps.icon AS status_icon, (SELECT name FROM clients WHERE id = projects.client_id) AS client_name, + projects.use_manual_progress, + projects.use_weighted_progress, + projects.use_time_progress, (SELECT COALESCE(ROW_TO_JSON(pm), '{}'::JSON) FROM (SELECT team_member_id AS id, diff --git a/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts b/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts new file mode 100644 index 00000000..4d399530 --- /dev/null +++ b/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts @@ -0,0 +1,68 @@ +import { Socket } from "socket.io"; +import db from "../../config/db"; +import { SocketEvents } from "../events"; +import { log, log_error, notifyProjectUpdates } from "../util"; + +interface UpdateTaskProgressData { + task_id: string; + progress_value: number; + parent_task_id: string | null; +} + +export async function on_update_task_progress(io: any, socket: Socket, data: string) { + try { + log(socket.id, `${SocketEvents.UPDATE_TASK_PROGRESS}: ${data}`); + + const parsedData = JSON.parse(data) as UpdateTaskProgressData; + const { task_id, progress_value, parent_task_id } = parsedData; + + if (!task_id || progress_value === undefined) { + return; + } + + // Update the task progress in the database + await db.query( + `UPDATE tasks + SET progress_value = $1, manual_progress = true, updated_at = NOW() + WHERE id = $2`, + [progress_value, task_id] + ); + + // Get the project ID for the task + const projectResult = await db.query("SELECT project_id FROM tasks WHERE id = $1", [task_id]); + const projectId = projectResult.rows[0]?.project_id; + + if (projectId) { + // Emit the update to all clients in the project room + io.to(projectId).emit( + SocketEvents.TASK_PROGRESS_UPDATED.toString(), + { + task_id, + progress_value + } + ); + + // If this is a subtask, update the parent task's progress + if (parent_task_id) { + const progressRatio = await db.query( + "SELECT get_task_complete_ratio($1) as ratio", + [parent_task_id] + ); + + // Emit the parent task's updated progress + io.to(projectId).emit( + SocketEvents.TASK_PROGRESS_UPDATED.toString(), + { + task_id: parent_task_id, + progress_value: progressRatio?.rows[0]?.ratio + } + ); + } + + // Notify that project updates are available + notifyProjectUpdates(socket, task_id); + } + } catch (error) { + log_error(error); + } +} \ No newline at end of file diff --git a/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts b/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts new file mode 100644 index 00000000..146aa3ec --- /dev/null +++ b/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts @@ -0,0 +1,68 @@ +import { Socket } from "socket.io"; +import db from "../../config/db"; +import { SocketEvents } from "../events"; +import { log, log_error, notifyProjectUpdates } from "../util"; + +interface UpdateTaskWeightData { + task_id: string; + weight: number; + parent_task_id: string | null; +} + +export async function on_update_task_weight(io: any, socket: Socket, data: string) { + try { + log(socket.id, `${SocketEvents.UPDATE_TASK_WEIGHT}: ${data}`); + + const parsedData = JSON.parse(data) as UpdateTaskWeightData; + const { task_id, weight, parent_task_id } = parsedData; + + if (!task_id || weight === undefined) { + return; + } + + // Update the task weight in the database + await db.query( + `UPDATE tasks + SET weight = $1, updated_at = NOW() + WHERE id = $2`, + [weight, task_id] + ); + + // Get the project ID for the task + const projectResult = await db.query("SELECT project_id FROM tasks WHERE id = $1", [task_id]); + const projectId = projectResult.rows[0]?.project_id; + + if (projectId) { + // Emit the update to all clients in the project room + io.to(projectId).emit( + SocketEvents.TASK_PROGRESS_UPDATED.toString(), + { + task_id, + weight + } + ); + + // If this is a subtask, update the parent task's progress + if (parent_task_id) { + const progressRatio = await db.query( + "SELECT get_task_complete_ratio($1) as ratio", + [parent_task_id] + ); + + // Emit the parent task's updated progress + io.to(projectId).emit( + SocketEvents.TASK_PROGRESS_UPDATED.toString(), + { + task_id: parent_task_id, + progress_value: progressRatio?.rows[0]?.ratio + } + ); + } + + // Notify that project updates are available + notifyProjectUpdates(socket, task_id); + } + } catch (error) { + log_error(error); + } +} \ No newline at end of file diff --git a/worklenz-backend/src/socket.io/events.ts b/worklenz-backend/src/socket.io/events.ts index 398ff030..c59b0eff 100644 --- a/worklenz-backend/src/socket.io/events.ts +++ b/worklenz-backend/src/socket.io/events.ts @@ -57,4 +57,10 @@ export enum SocketEvents { TASK_ASSIGNEES_CHANGE, TASK_CUSTOM_COLUMN_UPDATE, CUSTOM_COLUMN_PINNED_CHANGE, + TEAM_MEMBER_ROLE_CHANGE, + + // Task progress events + UPDATE_TASK_PROGRESS, + UPDATE_TASK_WEIGHT, + TASK_PROGRESS_UPDATED, } diff --git a/worklenz-backend/src/socket.io/index.ts b/worklenz-backend/src/socket.io/index.ts index 29c4b147..b77a68ea 100644 --- a/worklenz-backend/src/socket.io/index.ts +++ b/worklenz-backend/src/socket.io/index.ts @@ -52,6 +52,8 @@ import { on_task_recurring_change } from "./commands/on-task-recurring-change"; import { on_task_assignees_change } from "./commands/on-task-assignees-change"; import { on_task_custom_column_update } from "./commands/on_custom_column_update"; import { on_custom_column_pinned_change } from "./commands/on_custom_column_pinned_change"; +import { on_update_task_progress } from "./commands/on-update-task-progress"; +import { on_update_task_weight } from "./commands/on-update-task-weight"; export function register(io: any, socket: Socket) { log(socket.id, "client registered"); @@ -106,6 +108,8 @@ export function register(io: any, socket: Socket) { socket.on(SocketEvents.TASK_ASSIGNEES_CHANGE.toString(), data => on_task_assignees_change(io, socket, data)); socket.on(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), data => on_task_custom_column_update(io, socket, data)); socket.on(SocketEvents.CUSTOM_COLUMN_PINNED_CHANGE.toString(), data => on_custom_column_pinned_change(io, socket, data)); + socket.on(SocketEvents.UPDATE_TASK_PROGRESS.toString(), data => on_update_task_progress(io, socket, data)); + socket.on(SocketEvents.UPDATE_TASK_WEIGHT.toString(), data => on_update_task_weight(io, socket, data)); // socket.io built-in event socket.on("disconnect", (reason) => on_disconnect(io, socket, reason)); diff --git a/worklenz-frontend/public/locales/en/project-drawer.json b/worklenz-frontend/public/locales/en/project-drawer.json index d72138d6..53d4ea7e 100644 --- a/worklenz-frontend/public/locales/en/project-drawer.json +++ b/worklenz-frontend/public/locales/en/project-drawer.json @@ -38,5 +38,12 @@ "createClient": "Create client", "searchInputPlaceholder": "Search by name or email", "hoursPerDayValidationMessage": "Hours per day must be a number between 1 and 24", - "noPermission": "No permission" + "noPermission": "No permission", + "progressSettings": "Progress Settings", + "manualProgress": "Manual Progress", + "manualProgressTooltip": "Allow manual progress updates for tasks without subtasks", + "weightedProgress": "Weighted Progress", + "weightedProgressTooltip": "Calculate progress based on subtask weights", + "timeProgress": "Time-based Progress", + "timeProgressTooltip": "Calculate progress based on estimated time" } diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json index 003fa112..d957b891 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json @@ -22,7 +22,15 @@ "hide-start-date": "Hide Start Date", "show-start-date": "Show Start Date", "hours": "Hours", - "minutes": "Minutes" + "minutes": "Minutes", + "progressValue": "Progress Value", + "progressValueTooltip": "Set the progress percentage (0-100%)", + "progressValueRequired": "Please enter a progress value", + "progressValueRange": "Progress must be between 0 and 100", + "taskWeight": "Task Weight", + "taskWeightTooltip": "Set the weight of this subtask (percentage)", + "taskWeightRequired": "Please enter a task weight", + "taskWeightRange": "Weight must be between 0 and 100" }, "labels": { "labelInputPlaceholder": "Search or create", diff --git a/worklenz-frontend/public/locales/es/project-drawer.json b/worklenz-frontend/public/locales/es/project-drawer.json index 2dc114cc..411d6f69 100644 --- a/worklenz-frontend/public/locales/es/project-drawer.json +++ b/worklenz-frontend/public/locales/es/project-drawer.json @@ -38,5 +38,12 @@ "createClient": "Crear cliente", "searchInputPlaceholder": "Busca por nombre o email", "hoursPerDayValidationMessage": "Las horas por día deben ser un número entre 1 y 24", - "noPermission": "Sin permiso" + "noPermission": "Sin permiso", + "progressSettings": "Configuración de Progreso", + "manualProgress": "Progreso Manual", + "manualProgressTooltip": "Permitir actualizaciones manuales de progreso para tareas sin subtareas", + "weightedProgress": "Progreso Ponderado", + "weightedProgressTooltip": "Calcular el progreso basado en los pesos de las subtareas", + "timeProgress": "Progreso Basado en Tiempo", + "timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado" } diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json index 387968e9..d61bfd47 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json @@ -22,7 +22,15 @@ "hide-start-date": "Ocultar fecha de inicio", "show-start-date": "Mostrar fecha de inicio", "hours": "Horas", - "minutes": "Minutos" + "minutes": "Minutos", + "progressValue": "Valor de Progreso", + "progressValueTooltip": "Establecer el porcentaje de progreso (0-100%)", + "progressValueRequired": "Por favor, introduce un valor de progreso", + "progressValueRange": "El progreso debe estar entre 0 y 100", + "taskWeight": "Peso de la Tarea", + "taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)", + "taskWeightRequired": "Por favor, introduce un peso para la tarea", + "taskWeightRange": "El peso debe estar entre 0 y 100" }, "labels": { "labelInputPlaceholder": "Buscar o crear", diff --git a/worklenz-frontend/public/locales/pt/project-drawer.json b/worklenz-frontend/public/locales/pt/project-drawer.json index 55022c4e..471f8ed5 100644 --- a/worklenz-frontend/public/locales/pt/project-drawer.json +++ b/worklenz-frontend/public/locales/pt/project-drawer.json @@ -38,5 +38,12 @@ "createClient": "Criar cliente", "searchInputPlaceholder": "Pesquise por nome ou email", "hoursPerDayValidationMessage": "As horas por dia devem ser um número entre 1 e 24", - "noPermission": "Sem permissão" + "noPermission": "Sem permissão", + "progressSettings": "Configurações de Progresso", + "manualProgress": "Progresso Manual", + "manualProgressTooltip": "Permitir atualizações manuais de progresso para tarefas sem subtarefas", + "weightedProgress": "Progresso Ponderado", + "weightedProgressTooltip": "Calcular o progresso com base nos pesos das subtarefas", + "timeProgress": "Progresso Baseado em Tempo", + "timeProgressTooltip": "Calcular o progresso com base no tempo estimado" } diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json index d6e8fef6..0f0324c9 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json @@ -22,7 +22,15 @@ "hide-start-date": "Ocultar data de início", "show-start-date": "Mostrar data de início", "hours": "Horas", - "minutes": "Minutos" + "minutes": "Minutos", + "progressValue": "Valor de Progresso", + "progressValueTooltip": "Definir a porcentagem de progresso (0-100%)", + "progressValueRequired": "Por favor, insira um valor de progresso", + "progressValueRange": "O progresso deve estar entre 0 e 100", + "taskWeight": "Peso da Tarefa", + "taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)", + "taskWeightRequired": "Por favor, insira um peso para a tarefa", + "taskWeightRange": "O peso deve estar entre 0 e 100" }, "labels": { "labelInputPlaceholder": "Pesquisar ou criar", diff --git a/worklenz-frontend/src/api/projects/projects.api.service.ts b/worklenz-frontend/src/api/projects/projects.api.service.ts index a817e76e..0297dd22 100644 --- a/worklenz-frontend/src/api/projects/projects.api.service.ts +++ b/worklenz-frontend/src/api/projects/projects.api.service.ts @@ -10,6 +10,11 @@ import { IProjectManager } from '@/types/project/projectManager.types'; const rootUrl = `${API_BASE_URL}/projects`; +interface UpdateProjectPayload { + id: string; + [key: string]: any; +} + export const projectsApiService = { getProjects: async ( index: number, @@ -78,13 +83,11 @@ export const projectsApiService = { return response.data; }, - updateProject: async ( - id: string, - project: IProjectViewModel - ): Promise> => { + updateProject: async (payload: UpdateProjectPayload): Promise> => { + const { id, ...data } = payload; const q = toQueryString({ current_project_id: id }); - const url = `${rootUrl}/${id}${q}`; - const response = await apiClient.put>(`${url}`, project); + const url = `${API_BASE_URL}/projects/${id}${q}`; + const response = await apiClient.patch>(url, data); return response.data; }, diff --git a/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx b/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx index 35732ac3..27d24cb3 100644 --- a/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx +++ b/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx @@ -14,6 +14,7 @@ import { Popconfirm, Skeleton, Space, + Switch, Tooltip, Typography, } from 'antd'; @@ -96,6 +97,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { working_days: project?.working_days || 0, man_days: project?.man_days || 0, hours_per_day: project?.hours_per_day || 8, + use_manual_progress: project?.use_manual_progress || false, + use_weighted_progress: project?.use_weighted_progress || false, + use_time_progress: project?.use_time_progress || false, }), [project, projectStatuses, projectHealths] ); @@ -155,6 +159,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { man_days: parseInt(values.man_days), hours_per_day: parseInt(values.hours_per_day), project_manager: selectedProjectManager, + use_manual_progress: values.use_manual_progress || false, + use_weighted_progress: values.use_weighted_progress || false, + use_time_progress: values.use_time_progress || false, }; const action = @@ -214,6 +221,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { start_date: project.start_date ? dayjs(project.start_date) : null, end_date: project.end_date ? dayjs(project.end_date) : null, working_days: form.getFieldValue('start_date') && form.getFieldValue('end_date') ? calculateWorkingDays(form.getFieldValue('start_date'), form.getFieldValue('end_date')) : project.working_days || 0, + use_manual_progress: project.use_manual_progress || false, + use_weighted_progress: project.use_weighted_progress || false, + use_time_progress: project.use_time_progress || false, }); setSelectedProjectManager(project.project_manager || null); setLoading(false); @@ -284,6 +294,49 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { setIsFormValid(isValid); }; + // Progress calculation method handlers + const handleManualProgressChange = (checked: boolean) => { + if (checked) { + form.setFieldsValue({ + use_manual_progress: true, + use_weighted_progress: false, + use_time_progress: false, + }); + } else { + form.setFieldsValue({ + use_manual_progress: false, + }); + } + }; + + const handleWeightedProgressChange = (checked: boolean) => { + if (checked) { + form.setFieldsValue({ + use_manual_progress: false, + use_weighted_progress: true, + use_time_progress: false, + }); + } else { + form.setFieldsValue({ + use_weighted_progress: false, + }); + } + }; + + const handleTimeProgressChange = (checked: boolean) => { + if (checked) { + form.setFieldsValue({ + use_manual_progress: false, + use_weighted_progress: false, + use_time_progress: true, + }); + } else { + form.setFieldsValue({ + use_time_progress: false, + }); + } + }; + return ( void }) => { - {/* - - */} + + void }) => { > + + {t('progressSettings')} + + + {t('manualProgress')} + +