diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..06f61982 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(find:*)", + "Bash(npm run build:*)", + "Bash(npm run type-check:*)", + "Bash(npm run:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/test_sort_fix.sql b/test_sort_fix.sql new file mode 100644 index 00000000..ceb0b0a0 --- /dev/null +++ b/test_sort_fix.sql @@ -0,0 +1,41 @@ +-- Test script to verify the sort order constraint fix + +-- Test the helper function +SELECT get_sort_column_name('status'); -- Should return 'status_sort_order' +SELECT get_sort_column_name('priority'); -- Should return 'priority_sort_order' +SELECT get_sort_column_name('phase'); -- Should return 'phase_sort_order' +SELECT get_sort_column_name('members'); -- Should return 'member_sort_order' +SELECT get_sort_column_name('unknown'); -- Should return 'status_sort_order' (default) + +-- Test bulk update function (example - would need real project_id and task_ids) +/* +SELECT update_task_sort_orders_bulk( + '[ + {"task_id": "example-uuid", "sort_order": 1, "status_id": "status-uuid"}, + {"task_id": "example-uuid-2", "sort_order": 2, "status_id": "status-uuid"} + ]'::json, + 'status' +); +*/ + +-- Verify that sort_order constraint still exists and works +SELECT + tc.constraint_name, + tc.table_name, + kcu.column_name +FROM information_schema.table_constraints tc +JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name +WHERE tc.constraint_name = 'tasks_sort_order_unique'; + +-- Check that new sort order columns don't have unique constraints (which is correct) +SELECT + tc.constraint_name, + tc.table_name, + kcu.column_name +FROM information_schema.table_constraints tc +JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name +WHERE kcu.table_name = 'tasks' + AND kcu.column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order') + AND tc.constraint_type = 'UNIQUE'; \ No newline at end of file diff --git a/test_sort_orders.sql b/test_sort_orders.sql new file mode 100644 index 00000000..6a45de84 --- /dev/null +++ b/test_sort_orders.sql @@ -0,0 +1,30 @@ +-- Test script to validate the separate sort order implementation + +-- Check if new columns exist +SELECT column_name, data_type, is_nullable, column_default +FROM information_schema.columns +WHERE table_name = 'tasks' + AND column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order') +ORDER BY column_name; + +-- Check if helper function exists +SELECT routine_name, routine_type +FROM information_schema.routines +WHERE routine_name IN ('get_sort_column_name', 'update_task_sort_orders_bulk', 'handle_task_list_sort_order_change'); + +-- Sample test data to verify different sort orders work +-- (This would be run after the migrations) +/* +-- Test: Tasks should have different orders for different groupings +SELECT + id, + name, + sort_order, + status_sort_order, + priority_sort_order, + phase_sort_order, + member_sort_order +FROM tasks +WHERE project_id = '' +ORDER BY status_sort_order; +*/ \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250715000000-add-grouping-sort-orders.sql b/worklenz-backend/database/migrations/20250715000000-add-grouping-sort-orders.sql new file mode 100644 index 00000000..7fc4efec --- /dev/null +++ b/worklenz-backend/database/migrations/20250715000000-add-grouping-sort-orders.sql @@ -0,0 +1,37 @@ +-- Migration: Add separate sort order columns for different grouping types +-- This allows users to maintain different task orders when switching between grouping views + +-- Add new sort order columns +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS status_sort_order INTEGER DEFAULT 0; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS priority_sort_order INTEGER DEFAULT 0; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS phase_sort_order INTEGER DEFAULT 0; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS member_sort_order INTEGER DEFAULT 0; + +-- Initialize new columns with current sort_order values +UPDATE tasks SET + status_sort_order = sort_order, + priority_sort_order = sort_order, + phase_sort_order = sort_order, + member_sort_order = sort_order +WHERE status_sort_order = 0 + OR priority_sort_order = 0 + OR phase_sort_order = 0 + OR member_sort_order = 0; + +-- Add constraints to ensure non-negative values +ALTER TABLE tasks ADD CONSTRAINT tasks_status_sort_order_check CHECK (status_sort_order >= 0); +ALTER TABLE tasks ADD CONSTRAINT tasks_priority_sort_order_check CHECK (priority_sort_order >= 0); +ALTER TABLE tasks ADD CONSTRAINT tasks_phase_sort_order_check CHECK (phase_sort_order >= 0); +ALTER TABLE tasks ADD CONSTRAINT tasks_member_sort_order_check CHECK (member_sort_order >= 0); + +-- Add indexes for performance (since these will be used for ordering) +CREATE INDEX IF NOT EXISTS idx_tasks_status_sort_order ON tasks(project_id, status_sort_order); +CREATE INDEX IF NOT EXISTS idx_tasks_priority_sort_order ON tasks(project_id, priority_sort_order); +CREATE INDEX IF NOT EXISTS idx_tasks_phase_sort_order ON tasks(project_id, phase_sort_order); +CREATE INDEX IF NOT EXISTS idx_tasks_member_sort_order ON tasks(project_id, member_sort_order); + +-- Update comments for documentation +COMMENT ON COLUMN tasks.status_sort_order IS 'Sort order when grouped by status'; +COMMENT ON COLUMN tasks.priority_sort_order IS 'Sort order when grouped by priority'; +COMMENT ON COLUMN tasks.phase_sort_order IS 'Sort order when grouped by phase'; +COMMENT ON COLUMN tasks.member_sort_order IS 'Sort order when grouped by members/assignees'; \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250715000001-update-sort-functions.sql b/worklenz-backend/database/migrations/20250715000001-update-sort-functions.sql new file mode 100644 index 00000000..ada55087 --- /dev/null +++ b/worklenz-backend/database/migrations/20250715000001-update-sort-functions.sql @@ -0,0 +1,172 @@ +-- Migration: Update database functions to handle grouping-specific sort orders + +-- Function to get the appropriate sort column name based on grouping type +CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT + LANGUAGE plpgsql +AS +$$ +BEGIN + CASE _group_by + WHEN 'status' THEN RETURN 'status_sort_order'; + WHEN 'priority' THEN RETURN 'priority_sort_order'; + WHEN 'phase' THEN RETURN 'phase_sort_order'; + WHEN 'members' THEN RETURN 'member_sort_order'; + ELSE RETURN 'sort_order'; -- fallback to general sort_order + END CASE; +END; +$$; + +-- Updated bulk sort order function to handle different sort columns +CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _update_record RECORD; + _sort_column TEXT; + _sql TEXT; +BEGIN + -- Get the appropriate sort column based on grouping + _sort_column := get_sort_column_name(_group_by); + + -- Simple approach: update each task's sort_order from the provided array + FOR _update_record IN + SELECT + (item->>'task_id')::uuid as task_id, + (item->>'sort_order')::int as sort_order, + (item->>'status_id')::uuid as status_id, + (item->>'priority_id')::uuid as priority_id, + (item->>'phase_id')::uuid as phase_id + FROM json_array_elements(_updates) as item + LOOP + -- Update the appropriate sort column and other fields using dynamic SQL + -- Only update sort_order if we're using the default sorting + IF _sort_column = 'sort_order' THEN + UPDATE tasks SET + sort_order = _update_record.sort_order, + status_id = COALESCE(_update_record.status_id, status_id), + priority_id = COALESCE(_update_record.priority_id, priority_id) + WHERE id = _update_record.task_id; + ELSE + -- Update only the grouping-specific sort column, not the main sort_order + _sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' || + 'status_id = COALESCE($2, status_id), ' || + 'priority_id = COALESCE($3, priority_id) ' || + 'WHERE id = $4'; + + EXECUTE _sql USING + _update_record.sort_order, + _update_record.status_id, + _update_record.priority_id, + _update_record.task_id; + END IF; + + -- Handle phase updates separately since it's in a different table + IF _update_record.phase_id IS NOT NULL THEN + INSERT INTO task_phase (task_id, phase_id) + VALUES (_update_record.task_id, _update_record.phase_id) + ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id; + END IF; + END LOOP; +END; +$$; + +-- Updated main sort order change handler +CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _from_index INT; + _to_index INT; + _task_id UUID; + _project_id UUID; + _from_group UUID; + _to_group UUID; + _group_by TEXT; + _batch_size INT := 100; + _sort_column TEXT; + _sql TEXT; +BEGIN + _project_id = (_body ->> 'project_id')::UUID; + _task_id = (_body ->> 'task_id')::UUID; + _from_index = (_body ->> 'from_index')::INT; + _to_index = (_body ->> 'to_index')::INT; + _from_group = (_body ->> 'from_group')::UUID; + _to_group = (_body ->> 'to_group')::UUID; + _group_by = (_body ->> 'group_by')::TEXT; + + -- Get the appropriate sort column + _sort_column := get_sort_column_name(_group_by); + + -- Handle group changes + IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN + IF (_group_by = 'status') THEN + UPDATE tasks + SET status_id = _to_group + WHERE id = _task_id + AND status_id = _from_group + AND project_id = _project_id; + END IF; + + IF (_group_by = 'priority') THEN + UPDATE tasks + SET priority_id = _to_group + WHERE id = _task_id + AND priority_id = _from_group + AND project_id = _project_id; + END IF; + + IF (_group_by = 'phase') THEN + IF (is_null_or_empty(_to_group) IS FALSE) THEN + INSERT INTO task_phase (task_id, phase_id) + VALUES (_task_id, _to_group) + ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group; + ELSE + DELETE FROM task_phase WHERE task_id = _task_id; + END IF; + END IF; + END IF; + + -- Handle sort order changes using dynamic SQL + IF (_from_index <> _to_index) THEN + -- For the main sort_order column, we need to be careful about unique constraints + IF _sort_column = 'sort_order' THEN + -- Use a transaction-safe approach for the main sort_order column + IF (_to_index > _from_index) THEN + -- Moving down: decrease sort_order for items between old and new position + UPDATE tasks SET sort_order = sort_order - 1 + WHERE project_id = _project_id + AND sort_order > _from_index + AND sort_order <= _to_index; + ELSE + -- Moving up: increase sort_order for items between new and old position + UPDATE tasks SET sort_order = sort_order + 1 + WHERE project_id = _project_id + AND sort_order >= _to_index + AND sort_order < _from_index; + END IF; + + -- Set the new sort_order for the moved task + UPDATE tasks SET sort_order = _to_index WHERE id = _task_id; + ELSE + -- For grouping-specific columns, use dynamic SQL since there's no unique constraint + IF (_to_index > _from_index) THEN + -- Moving down: decrease sort_order for items between old and new position + _sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1 ' || + 'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3'; + EXECUTE _sql USING _project_id, _from_index, _to_index; + ELSE + -- Moving up: increase sort_order for items between new and old position + _sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1 ' || + 'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3'; + EXECUTE _sql USING _project_id, _to_index, _from_index; + END IF; + + -- Set the new sort_order for the moved task + _sql := 'UPDATE tasks SET ' || _sort_column || ' = $1 WHERE id = $2'; + EXECUTE _sql USING _to_index, _task_id; + END IF; + END IF; +END; +$$; \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250715000002-fix-sort-constraint.sql b/worklenz-backend/database/migrations/20250715000002-fix-sort-constraint.sql new file mode 100644 index 00000000..8068d9aa --- /dev/null +++ b/worklenz-backend/database/migrations/20250715000002-fix-sort-constraint.sql @@ -0,0 +1,179 @@ +-- Migration: Fix sort order constraint violations + +-- First, let's ensure all existing tasks have unique sort_order values within each project +-- This is a one-time fix to ensure data consistency + +DO $$ +DECLARE + _project RECORD; + _task RECORD; + _counter INTEGER; +BEGIN + -- For each project, reassign sort_order values to ensure uniqueness + FOR _project IN + SELECT DISTINCT project_id + FROM tasks + WHERE project_id IS NOT NULL + LOOP + _counter := 0; + + -- Reassign sort_order values sequentially for this project + FOR _task IN + SELECT id + FROM tasks + WHERE project_id = _project.project_id + ORDER BY sort_order, created_at + LOOP + UPDATE tasks + SET sort_order = _counter + WHERE id = _task.id; + + _counter := _counter + 1; + END LOOP; + END LOOP; +END +$$; + +-- Now create a better version of our functions that properly handles the constraints + +-- Updated bulk sort order function that avoids sort_order conflicts +CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _update_record RECORD; + _sort_column TEXT; + _sql TEXT; +BEGIN + -- Get the appropriate sort column based on grouping + _sort_column := get_sort_column_name(_group_by); + + -- Process each update record + FOR _update_record IN + SELECT + (item->>'task_id')::uuid as task_id, + (item->>'sort_order')::int as sort_order, + (item->>'status_id')::uuid as status_id, + (item->>'priority_id')::uuid as priority_id, + (item->>'phase_id')::uuid as phase_id + FROM json_array_elements(_updates) as item + LOOP + -- Update the grouping-specific sort column and other fields + _sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' || + 'status_id = COALESCE($2, status_id), ' || + 'priority_id = COALESCE($3, priority_id), ' || + 'updated_at = CURRENT_TIMESTAMP ' || + 'WHERE id = $4'; + + EXECUTE _sql USING + _update_record.sort_order, + _update_record.status_id, + _update_record.priority_id, + _update_record.task_id; + + -- Handle phase updates separately since it's in a different table + IF _update_record.phase_id IS NOT NULL THEN + INSERT INTO task_phase (task_id, phase_id) + VALUES (_update_record.task_id, _update_record.phase_id) + ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id; + END IF; + END LOOP; +END; +$$; + +-- Also update the helper function to be more explicit +CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT + LANGUAGE plpgsql +AS +$$ +BEGIN + CASE _group_by + WHEN 'status' THEN RETURN 'status_sort_order'; + WHEN 'priority' THEN RETURN 'priority_sort_order'; + WHEN 'phase' THEN RETURN 'phase_sort_order'; + WHEN 'members' THEN RETURN 'member_sort_order'; + -- For backward compatibility, still support general sort_order but be explicit + WHEN 'general' THEN RETURN 'sort_order'; + ELSE RETURN 'status_sort_order'; -- Default to status sorting + END CASE; +END; +$$; + +-- Updated main sort order change handler that avoids conflicts +CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _from_index INT; + _to_index INT; + _task_id UUID; + _project_id UUID; + _from_group UUID; + _to_group UUID; + _group_by TEXT; + _sort_column TEXT; + _sql TEXT; +BEGIN + _project_id = (_body ->> 'project_id')::UUID; + _task_id = (_body ->> 'task_id')::UUID; + _from_index = (_body ->> 'from_index')::INT; + _to_index = (_body ->> 'to_index')::INT; + _from_group = (_body ->> 'from_group')::UUID; + _to_group = (_body ->> 'to_group')::UUID; + _group_by = (_body ->> 'group_by')::TEXT; + + -- Get the appropriate sort column + _sort_column := get_sort_column_name(_group_by); + + -- Handle group changes first + IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN + IF (_group_by = 'status') THEN + UPDATE tasks + SET status_id = _to_group, updated_at = CURRENT_TIMESTAMP + WHERE id = _task_id + AND project_id = _project_id; + END IF; + + IF (_group_by = 'priority') THEN + UPDATE tasks + SET priority_id = _to_group, updated_at = CURRENT_TIMESTAMP + WHERE id = _task_id + AND project_id = _project_id; + END IF; + + IF (_group_by = 'phase') THEN + IF (is_null_or_empty(_to_group) IS FALSE) THEN + INSERT INTO task_phase (task_id, phase_id) + VALUES (_task_id, _to_group) + ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group; + ELSE + DELETE FROM task_phase WHERE task_id = _task_id; + END IF; + END IF; + END IF; + + -- Handle sort order changes for the grouping-specific column only + IF (_from_index <> _to_index) THEN + -- Update the grouping-specific sort order (no unique constraint issues) + IF (_to_index > _from_index) THEN + -- Moving down: decrease sort order for items between old and new position + _sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1, ' || + 'updated_at = CURRENT_TIMESTAMP ' || + 'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3'; + EXECUTE _sql USING _project_id, _from_index, _to_index; + ELSE + -- Moving up: increase sort order for items between new and old position + _sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1, ' || + 'updated_at = CURRENT_TIMESTAMP ' || + 'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3'; + EXECUTE _sql USING _project_id, _to_index, _from_index; + END IF; + + -- Set the new sort order for the moved task + _sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2'; + EXECUTE _sql USING _to_index, _task_id; + END IF; +END; +$$; \ No newline at end of file diff --git a/worklenz-backend/database/migrations/fix_duplicate_sort_orders.sql b/worklenz-backend/database/migrations/fix_duplicate_sort_orders.sql new file mode 100644 index 00000000..689edaf7 --- /dev/null +++ b/worklenz-backend/database/migrations/fix_duplicate_sort_orders.sql @@ -0,0 +1,300 @@ +-- Fix Duplicate Sort Orders Script +-- This script detects and fixes duplicate sort order values that break task ordering + +-- 1. DETECTION QUERIES - Run these first to see the scope of the problem + +-- Check for duplicates in main sort_order column +SELECT + project_id, + sort_order, + COUNT(*) as duplicate_count, + STRING_AGG(id::text, ', ') as task_ids +FROM tasks +WHERE project_id IS NOT NULL +GROUP BY project_id, sort_order +HAVING COUNT(*) > 1 +ORDER BY project_id, sort_order; + +-- Check for duplicates in status_sort_order +SELECT + project_id, + status_sort_order, + COUNT(*) as duplicate_count, + STRING_AGG(id::text, ', ') as task_ids +FROM tasks +WHERE project_id IS NOT NULL +GROUP BY project_id, status_sort_order +HAVING COUNT(*) > 1 +ORDER BY project_id, status_sort_order; + +-- Check for duplicates in priority_sort_order +SELECT + project_id, + priority_sort_order, + COUNT(*) as duplicate_count, + STRING_AGG(id::text, ', ') as task_ids +FROM tasks +WHERE project_id IS NOT NULL +GROUP BY project_id, priority_sort_order +HAVING COUNT(*) > 1 +ORDER BY project_id, priority_sort_order; + +-- Check for duplicates in phase_sort_order +SELECT + project_id, + phase_sort_order, + COUNT(*) as duplicate_count, + STRING_AGG(id::text, ', ') as task_ids +FROM tasks +WHERE project_id IS NOT NULL +GROUP BY project_id, phase_sort_order +HAVING COUNT(*) > 1 +ORDER BY project_id, phase_sort_order; + +-- Note: member_sort_order removed - no longer used + +-- 2. CLEANUP FUNCTIONS + +-- Fix duplicates in main sort_order column +CREATE OR REPLACE FUNCTION fix_sort_order_duplicates() RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _project RECORD; + _task RECORD; + _counter INTEGER; +BEGIN + -- For each project, reassign sort_order values to ensure uniqueness + FOR _project IN + SELECT DISTINCT project_id + FROM tasks + WHERE project_id IS NOT NULL + LOOP + _counter := 0; + + -- Reassign sort_order values sequentially for this project + FOR _task IN + SELECT id + FROM tasks + WHERE project_id = _project.project_id + ORDER BY sort_order, created_at + LOOP + UPDATE tasks + SET sort_order = _counter + WHERE id = _task.id; + + _counter := _counter + 1; + END LOOP; + END LOOP; + + RAISE NOTICE 'Fixed sort_order duplicates for all projects'; +END +$$; + +-- Fix duplicates in status_sort_order column +CREATE OR REPLACE FUNCTION fix_status_sort_order_duplicates() RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _project RECORD; + _task RECORD; + _counter INTEGER; +BEGIN + FOR _project IN + SELECT DISTINCT project_id + FROM tasks + WHERE project_id IS NOT NULL + LOOP + _counter := 0; + + FOR _task IN + SELECT id + FROM tasks + WHERE project_id = _project.project_id + ORDER BY status_sort_order, created_at + LOOP + UPDATE tasks + SET status_sort_order = _counter + WHERE id = _task.id; + + _counter := _counter + 1; + END LOOP; + END LOOP; + + RAISE NOTICE 'Fixed status_sort_order duplicates for all projects'; +END +$$; + +-- Fix duplicates in priority_sort_order column +CREATE OR REPLACE FUNCTION fix_priority_sort_order_duplicates() RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _project RECORD; + _task RECORD; + _counter INTEGER; +BEGIN + FOR _project IN + SELECT DISTINCT project_id + FROM tasks + WHERE project_id IS NOT NULL + LOOP + _counter := 0; + + FOR _task IN + SELECT id + FROM tasks + WHERE project_id = _project.project_id + ORDER BY priority_sort_order, created_at + LOOP + UPDATE tasks + SET priority_sort_order = _counter + WHERE id = _task.id; + + _counter := _counter + 1; + END LOOP; + END LOOP; + + RAISE NOTICE 'Fixed priority_sort_order duplicates for all projects'; +END +$$; + +-- Fix duplicates in phase_sort_order column +CREATE OR REPLACE FUNCTION fix_phase_sort_order_duplicates() RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _project RECORD; + _task RECORD; + _counter INTEGER; +BEGIN + FOR _project IN + SELECT DISTINCT project_id + FROM tasks + WHERE project_id IS NOT NULL + LOOP + _counter := 0; + + FOR _task IN + SELECT id + FROM tasks + WHERE project_id = _project.project_id + ORDER BY phase_sort_order, created_at + LOOP + UPDATE tasks + SET phase_sort_order = _counter + WHERE id = _task.id; + + _counter := _counter + 1; + END LOOP; + END LOOP; + + RAISE NOTICE 'Fixed phase_sort_order duplicates for all projects'; +END +$$; + +-- Note: fix_member_sort_order_duplicates() removed - no longer needed + +-- Master function to fix all sort order duplicates +CREATE OR REPLACE FUNCTION fix_all_duplicate_sort_orders() RETURNS void + LANGUAGE plpgsql +AS +$$ +BEGIN + RAISE NOTICE 'Starting sort order cleanup for all columns...'; + + PERFORM fix_sort_order_duplicates(); + PERFORM fix_status_sort_order_duplicates(); + PERFORM fix_priority_sort_order_duplicates(); + PERFORM fix_phase_sort_order_duplicates(); + + RAISE NOTICE 'Completed sort order cleanup for all columns'; +END +$$; + +-- 3. VERIFICATION FUNCTION + +-- Verify that duplicates have been fixed +CREATE OR REPLACE FUNCTION verify_sort_order_integrity() RETURNS TABLE( + column_name text, + project_id uuid, + duplicate_count bigint, + status text +) + LANGUAGE plpgsql +AS +$$ +BEGIN + -- Check sort_order duplicates + RETURN QUERY + SELECT + 'sort_order'::text as column_name, + t.project_id, + COUNT(*) as duplicate_count, + CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status + FROM tasks t + WHERE t.project_id IS NOT NULL + GROUP BY t.project_id, t.sort_order + HAVING COUNT(*) > 1; + + -- Check status_sort_order duplicates + RETURN QUERY + SELECT + 'status_sort_order'::text as column_name, + t.project_id, + COUNT(*) as duplicate_count, + CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status + FROM tasks t + WHERE t.project_id IS NOT NULL + GROUP BY t.project_id, t.status_sort_order + HAVING COUNT(*) > 1; + + -- Check priority_sort_order duplicates + RETURN QUERY + SELECT + 'priority_sort_order'::text as column_name, + t.project_id, + COUNT(*) as duplicate_count, + CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status + FROM tasks t + WHERE t.project_id IS NOT NULL + GROUP BY t.project_id, t.priority_sort_order + HAVING COUNT(*) > 1; + + -- Check phase_sort_order duplicates + RETURN QUERY + SELECT + 'phase_sort_order'::text as column_name, + t.project_id, + COUNT(*) as duplicate_count, + CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status + FROM tasks t + WHERE t.project_id IS NOT NULL + GROUP BY t.project_id, t.phase_sort_order + HAVING COUNT(*) > 1; + + -- Note: member_sort_order verification removed - column no longer used + +END +$$; + +-- 4. USAGE INSTRUCTIONS + +/* +USAGE: + +1. First, run the detection queries to see which projects have duplicates +2. Then run this to fix all duplicates: + SELECT fix_all_duplicate_sort_orders(); +3. Finally, verify the fix worked: + SELECT * FROM verify_sort_order_integrity(); + +If verification returns no rows, all duplicates have been fixed successfully. + +WARNING: This will reassign sort order values based on current order + creation time. +Make sure to backup your database before running these functions. +*/ \ No newline at end of file diff --git a/worklenz-backend/database/sql/1_tables.sql b/worklenz-backend/database/sql/1_tables.sql index 21f498f1..2ab00077 100644 --- a/worklenz-backend/database/sql/1_tables.sql +++ b/worklenz-backend/database/sql/1_tables.sql @@ -1391,27 +1391,30 @@ ALTER TABLE task_work_log CHECK (time_spent >= (0)::NUMERIC); CREATE TABLE IF NOT EXISTS tasks ( - id UUID DEFAULT uuid_generate_v4() NOT NULL, - name TEXT NOT NULL, - description TEXT, - done BOOLEAN DEFAULT FALSE NOT NULL, - total_minutes NUMERIC DEFAULT 0 NOT NULL, - archived BOOLEAN DEFAULT FALSE NOT NULL, - task_no BIGINT NOT NULL, - start_date TIMESTAMP WITH TIME ZONE, - end_date TIMESTAMP WITH TIME ZONE, - priority_id UUID NOT NULL, - project_id UUID NOT NULL, - reporter_id UUID NOT NULL, - parent_task_id UUID, - status_id UUID NOT NULL, - completed_at TIMESTAMP WITH TIME ZONE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, - sort_order INTEGER DEFAULT 0 NOT NULL, - roadmap_sort_order INTEGER DEFAULT 0 NOT NULL, - billable BOOLEAN DEFAULT TRUE, - schedule_id UUID + id UUID DEFAULT uuid_generate_v4() NOT NULL, + name TEXT NOT NULL, + description TEXT, + done BOOLEAN DEFAULT FALSE NOT NULL, + total_minutes NUMERIC DEFAULT 0 NOT NULL, + archived BOOLEAN DEFAULT FALSE NOT NULL, + task_no BIGINT NOT NULL, + start_date TIMESTAMP WITH TIME ZONE, + end_date TIMESTAMP WITH TIME ZONE, + priority_id UUID NOT NULL, + project_id UUID NOT NULL, + reporter_id UUID NOT NULL, + parent_task_id UUID, + status_id UUID NOT NULL, + completed_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + sort_order INTEGER DEFAULT 0 NOT NULL, + roadmap_sort_order INTEGER DEFAULT 0 NOT NULL, + status_sort_order INTEGER DEFAULT 0 NOT NULL, + priority_sort_order INTEGER DEFAULT 0 NOT NULL, + phase_sort_order INTEGER DEFAULT 0 NOT NULL, + billable BOOLEAN DEFAULT TRUE, + schedule_id UUID ); ALTER TABLE tasks @@ -1499,6 +1502,21 @@ ALTER TABLE tasks ADD CONSTRAINT tasks_total_minutes_check CHECK ((total_minutes >= (0)::NUMERIC) AND (total_minutes <= (999999)::NUMERIC)); +-- Add constraints for new sort order columns +ALTER TABLE tasks ADD CONSTRAINT tasks_status_sort_order_check CHECK (status_sort_order >= 0); +ALTER TABLE tasks ADD CONSTRAINT tasks_priority_sort_order_check CHECK (priority_sort_order >= 0); +ALTER TABLE tasks ADD CONSTRAINT tasks_phase_sort_order_check CHECK (phase_sort_order >= 0); + +-- Add indexes for performance on new sort order columns +CREATE INDEX IF NOT EXISTS idx_tasks_status_sort_order ON tasks(project_id, status_sort_order); +CREATE INDEX IF NOT EXISTS idx_tasks_priority_sort_order ON tasks(project_id, priority_sort_order); +CREATE INDEX IF NOT EXISTS idx_tasks_phase_sort_order ON tasks(project_id, phase_sort_order); + +-- Add comments for documentation +COMMENT ON COLUMN tasks.status_sort_order IS 'Sort order when grouped by status'; +COMMENT ON COLUMN tasks.priority_sort_order IS 'Sort order when grouped by priority'; +COMMENT ON COLUMN tasks.phase_sort_order IS 'Sort order when grouped by phase'; + CREATE TABLE IF NOT EXISTS tasks_assignees ( task_id UUID NOT NULL, project_member_id UUID NOT NULL, diff --git a/worklenz-backend/database/sql/4_functions.sql b/worklenz-backend/database/sql/4_functions.sql index 2c57d3c4..d2c752d2 100644 --- a/worklenz-backend/database/sql/4_functions.sql +++ b/worklenz-backend/database/sql/4_functions.sql @@ -4313,6 +4313,24 @@ BEGIN END $$; +-- Helper function to get the appropriate sort column name based on grouping type +CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT + LANGUAGE plpgsql +AS +$$ +BEGIN + CASE _group_by + WHEN 'status' THEN RETURN 'status_sort_order'; + WHEN 'priority' THEN RETURN 'priority_sort_order'; + WHEN 'phase' THEN RETURN 'phase_sort_order'; + WHEN 'members' THEN RETURN 'member_sort_order'; + -- For backward compatibility, still support general sort_order but be explicit + WHEN 'general' THEN RETURN 'sort_order'; + ELSE RETURN 'status_sort_order'; -- Default to status sorting + END CASE; +END; +$$; + CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void LANGUAGE plpgsql AS @@ -4325,66 +4343,67 @@ DECLARE _from_group UUID; _to_group UUID; _group_by TEXT; - _batch_size INT := 100; -- PERFORMANCE OPTIMIZATION: Batch size for large updates + _sort_column TEXT; + _sql TEXT; BEGIN _project_id = (_body ->> 'project_id')::UUID; _task_id = (_body ->> 'task_id')::UUID; - - _from_index = (_body ->> 'from_index')::INT; -- from sort_order - _to_index = (_body ->> 'to_index')::INT; -- to sort_order - + _from_index = (_body ->> 'from_index')::INT; + _to_index = (_body ->> 'to_index')::INT; _from_group = (_body ->> 'from_group')::UUID; _to_group = (_body ->> 'to_group')::UUID; - _group_by = (_body ->> 'group_by')::TEXT; - - -- PERFORMANCE OPTIMIZATION: Use CTE for better query planning - IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) - THEN - -- PERFORMANCE OPTIMIZATION: Batch update group changes - IF (_group_by = 'status') - THEN + + -- Get the appropriate sort column + _sort_column := get_sort_column_name(_group_by); + + -- Handle group changes first + IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN + IF (_group_by = 'status') THEN UPDATE tasks - SET status_id = _to_group + SET status_id = _to_group, updated_at = CURRENT_TIMESTAMP WHERE id = _task_id - AND status_id = _from_group AND project_id = _project_id; END IF; - - IF (_group_by = 'priority') - THEN + + IF (_group_by = 'priority') THEN UPDATE tasks - SET priority_id = _to_group + SET priority_id = _to_group, updated_at = CURRENT_TIMESTAMP WHERE id = _task_id - AND priority_id = _from_group AND project_id = _project_id; END IF; - - IF (_group_by = 'phase') - THEN - IF (is_null_or_empty(_to_group) IS FALSE) - THEN + + IF (_group_by = 'phase') THEN + IF (is_null_or_empty(_to_group) IS FALSE) THEN INSERT INTO task_phase (task_id, phase_id) VALUES (_task_id, _to_group) ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group; - END IF; - IF (is_null_or_empty(_to_group) IS TRUE) - THEN - DELETE - FROM task_phase - WHERE task_id = _task_id; + ELSE + DELETE FROM task_phase WHERE task_id = _task_id; END IF; END IF; + END IF; - -- PERFORMANCE OPTIMIZATION: Optimized sort order handling - IF ((_body ->> 'to_last_index')::BOOLEAN IS TRUE AND _from_index < _to_index) - THEN - PERFORM handle_task_list_sort_inside_group_optimized(_from_index, _to_index, _task_id, _project_id, _batch_size); + -- Handle sort order changes for the grouping-specific column only + IF (_from_index <> _to_index) THEN + -- Update the grouping-specific sort order (no unique constraint issues) + IF (_to_index > _from_index) THEN + -- Moving down: decrease sort order for items between old and new position + _sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1, ' || + 'updated_at = CURRENT_TIMESTAMP ' || + 'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3'; + EXECUTE _sql USING _project_id, _from_index, _to_index; ELSE - PERFORM handle_task_list_sort_between_groups_optimized(_from_index, _to_index, _task_id, _project_id, _batch_size); + -- Moving up: increase sort order for items between new and old position + _sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1, ' || + 'updated_at = CURRENT_TIMESTAMP ' || + 'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3'; + EXECUTE _sql USING _project_id, _to_index, _from_index; END IF; - ELSE - PERFORM handle_task_list_sort_inside_group_optimized(_from_index, _to_index, _task_id, _project_id, _batch_size); + + -- Set the new sort order for the moved task + _sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2'; + EXECUTE _sql USING _to_index, _task_id; END IF; END $$; @@ -4589,31 +4608,31 @@ BEGIN INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) VALUES (_project_id, 'Progress', 'PROGRESS', 3, TRUE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Members', 'ASSIGNEES', 4, TRUE); + VALUES (_project_id, 'Status', 'STATUS', 4, TRUE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Labels', 'LABELS', 5, TRUE); + VALUES (_project_id, 'Members', 'ASSIGNEES', 5, TRUE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Status', 'STATUS', 6, TRUE); + VALUES (_project_id, 'Labels', 'LABELS', 6, TRUE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Priority', 'PRIORITY', 7, TRUE); + VALUES (_project_id, 'Phase', 'PHASE', 7, TRUE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Time Tracking', 'TIME_TRACKING', 8, TRUE); + VALUES (_project_id, 'Priority', 'PRIORITY', 8, TRUE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Estimation', 'ESTIMATION', 9, FALSE); + VALUES (_project_id, 'Time Tracking', 'TIME_TRACKING', 9, TRUE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Start Date', 'START_DATE', 10, FALSE); + VALUES (_project_id, 'Estimation', 'ESTIMATION', 10, FALSE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Due Date', 'DUE_DATE', 11, TRUE); + VALUES (_project_id, 'Start Date', 'START_DATE', 11, FALSE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Completed Date', 'COMPLETED_DATE', 12, FALSE); + VALUES (_project_id, 'Due Date', 'DUE_DATE', 12, TRUE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Created Date', 'CREATED_DATE', 13, FALSE); + VALUES (_project_id, 'Completed Date', 'COMPLETED_DATE', 13, FALSE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Last Updated', 'LAST_UPDATED', 14, FALSE); + VALUES (_project_id, 'Created Date', 'CREATED_DATE', 14, FALSE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Reporter', 'REPORTER', 15, FALSE); + VALUES (_project_id, 'Last Updated', 'LAST_UPDATED', 15, FALSE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Phase', 'PHASE', 16, FALSE); + VALUES (_project_id, 'Reporter', 'REPORTER', 16, FALSE); END $$; @@ -6521,15 +6540,20 @@ BEGIN END $$; --- Simple function to update task sort orders in bulk -CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json) RETURNS void +-- Updated bulk sort order function that avoids sort_order conflicts +CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void LANGUAGE plpgsql AS $$ DECLARE _update_record RECORD; + _sort_column TEXT; + _sql TEXT; BEGIN - -- Simple approach: update each task's sort_order from the provided array + -- Get the appropriate sort column based on grouping + _sort_column := get_sort_column_name(_group_by); + + -- Process each update record FOR _update_record IN SELECT (item->>'task_id')::uuid as task_id, @@ -6539,12 +6563,18 @@ BEGIN (item->>'phase_id')::uuid as phase_id FROM json_array_elements(_updates) as item LOOP - UPDATE tasks - SET - sort_order = _update_record.sort_order, - status_id = COALESCE(_update_record.status_id, status_id), - priority_id = COALESCE(_update_record.priority_id, priority_id) - WHERE id = _update_record.task_id; + -- Update the grouping-specific sort column and other fields + _sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' || + 'status_id = COALESCE($2, status_id), ' || + 'priority_id = COALESCE($3, priority_id), ' || + 'updated_at = CURRENT_TIMESTAMP ' || + 'WHERE id = $4'; + + EXECUTE _sql USING + _update_record.sort_order, + _update_record.status_id, + _update_record.priority_id, + _update_record.task_id; -- Handle phase updates separately since it's in a different table IF _update_record.phase_id IS NOT NULL THEN @@ -6555,3 +6585,66 @@ BEGIN END LOOP; END $$; + +-- Function to get the appropriate sort column name based on grouping type +CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT + LANGUAGE plpgsql +AS +$$ +BEGIN + CASE _group_by + WHEN 'status' THEN RETURN 'status_sort_order'; + WHEN 'priority' THEN RETURN 'priority_sort_order'; + WHEN 'phase' THEN RETURN 'phase_sort_order'; + -- For backward compatibility, still support general sort_order but be explicit + WHEN 'general' THEN RETURN 'sort_order'; + ELSE RETURN 'status_sort_order'; -- Default to status sorting + END CASE; +END; +$$; + +-- Updated bulk sort order function to handle different sort columns +CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _update_record RECORD; + _sort_column TEXT; + _sql TEXT; +BEGIN + -- Get the appropriate sort column based on grouping + _sort_column := get_sort_column_name(_group_by); + + -- Process each update record + FOR _update_record IN + SELECT + (item->>'task_id')::uuid as task_id, + (item->>'sort_order')::int as sort_order, + (item->>'status_id')::uuid as status_id, + (item->>'priority_id')::uuid as priority_id, + (item->>'phase_id')::uuid as phase_id + FROM json_array_elements(_updates) as item + LOOP + -- Update the grouping-specific sort column and other fields + _sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' || + 'status_id = COALESCE($2, status_id), ' || + 'priority_id = COALESCE($3, priority_id), ' || + 'updated_at = CURRENT_TIMESTAMP ' || + 'WHERE id = $4'; + + EXECUTE _sql USING + _update_record.sort_order, + _update_record.status_id, + _update_record.priority_id, + _update_record.task_id; + + -- Handle phase updates separately since it's in a different table + IF _update_record.phase_id IS NOT NULL THEN + INSERT INTO task_phase (task_id, phase_id) + VALUES (_update_record.task_id, _update_record.phase_id) + ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id; + END IF; + END LOOP; +END; +$$; diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index d941f824..d38a563d 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -109,12 +109,29 @@ export default class TasksControllerV2 extends TasksControllerBase { } private static getQuery(userId: string, options: ParsedQs) { - const searchField = options.search ? ["t.name", "CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no)"] : "sort_order"; + // Determine which sort column to use based on grouping + const groupBy = options.group || 'status'; + let defaultSortColumn = 'sort_order'; + switch (groupBy) { + case 'status': + defaultSortColumn = 'status_sort_order'; + break; + case 'priority': + defaultSortColumn = 'priority_sort_order'; + break; + case 'phase': + defaultSortColumn = 'phase_sort_order'; + break; + default: + 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 isSubTasks = !!options.parent_task; - const sortFields = sortField.replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || "sort_order"; + const sortFields = sortField.replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || defaultSortColumn; // Filter tasks by statuses const statusesFilter = TasksControllerV2.getFilterByStatusWhereClosure(options.statuses as string); @@ -196,6 +213,9 @@ export default class TasksControllerV2 extends TasksControllerBase { t.archived, t.description, t.sort_order, + t.status_sort_order, + t.priority_sort_order, + t.phase_sort_order, t.progress_value, t.manual_progress, t.weight, @@ -1088,7 +1108,7 @@ export default class TasksControllerV2 extends TasksControllerBase { custom_column_values: task.custom_column_values || {}, // Include custom column values createdAt: task.created_at || new Date().toISOString(), updatedAt: task.updated_at || new Date().toISOString(), - order: typeof task.sort_order === "number" ? task.sort_order : 0, + order: TasksControllerV2.getTaskSortOrder(task, groupBy), // Additional metadata for frontend originalStatusId: task.status, originalPriorityId: task.priority, @@ -1292,6 +1312,19 @@ export default class TasksControllerV2 extends TasksControllerBase { })); } + 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; + case GroupBy.PRIORITY: + 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; + default: + return typeof task.sort_order === "number" ? task.sort_order : 0; + } + } + private static getDefaultGroupColor(groupBy: string, groupValue: string): string { const colorMaps: Record> = { [GroupBy.STATUS]: { diff --git a/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts b/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts index 11ec09cd..8493df10 100644 --- a/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts +++ b/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts @@ -53,11 +53,27 @@ function notifyStatusChange(socket: Socket, config: Config) { } async function emitSortOrderChange(data: ChangeRequest, socket: Socket) { + // Determine which sort column to use based on group_by + let sortColumn = "sort_order"; + switch (data.group_by) { + case "status": + sortColumn = "status_sort_order"; + break; + case "priority": + sortColumn = "priority_sort_order"; + break; + case "phase": + sortColumn = "phase_sort_order"; + break; + default: + sortColumn = "sort_order"; + } + const q = ` - SELECT id, sort_order, completed_at + SELECT id, sort_order, ${sortColumn} as current_sort_order, completed_at FROM tasks WHERE project_id = $1 - ORDER BY sort_order; + ORDER BY ${sortColumn}; `; const tasks = await db.query(q, [data.project_id]); socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), tasks.rows); @@ -84,9 +100,9 @@ export async function on_task_sort_order_change(_io: Server, socket: Socket, dat } } - // Use the simple bulk update function - const q = `SELECT update_task_sort_orders_bulk($1);`; - await db.query(q, [JSON.stringify(data.task_updates)]); + // Use the simple bulk update function with group_by parameter + const q = `SELECT update_task_sort_orders_bulk($1, $2);`; + await db.query(q, [JSON.stringify(data.task_updates), data.group_by || "status"]); await emitSortOrderChange(data, socket); // Handle notifications and logging diff --git a/worklenz-frontend/public/locales/alb/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/alb/task-drawer/task-drawer.json index 9d6c022f..e8944656 100644 --- a/worklenz-frontend/public/locales/alb/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/alb/task-drawer/task-drawer.json @@ -1,37 +1,43 @@ { "taskHeader": { - "taskNamePlaceholder": "Shkruani Detyrën tuaj", - "deleteTask": "Fshi Detyrën" + "taskNamePlaceholder": "Shkruani detyrën tuaj", + "deleteTask": "Fshi detyrën", + "parentTask": "Detyra kryesore", + "currentTask": "Detyra aktuale", + "back": "Kthehu", + "backToParent": "Kthehu te detyra kryesore", + "toParentTask": "te detyra kryesore", + "loadingHierarchy": "Duke ngarkuar hierarkinë..." }, "taskInfoTab": { "title": "Informacioni", "details": { "title": "Detajet", - "task-key": "Çelësi i Detyrës", + "task-key": "Çelësi i detyrës", "phase": "Faza", - "assignees": "Të Caktuar", - "due-date": "Data e Përfundimit", - "time-estimation": "Vlerësimi i Kohës", + "assignees": "Të caktuarit", + "due-date": "Data e përfundimit", + "time-estimation": "Vlerësimi i kohës", "priority": "Prioriteti", "labels": "Etiketat", - "billable": "E Faturueshme", + "billable": "I faturueshëm", "notify": "Njofto", "when-done-notify": "Kur përfundon, njofto", - "start-date": "Data e Fillimit", - "end-date": "Data e Përfundimit", - "hide-start-date": "Fshih Datën e Fillimit", - "show-start-date": "Shfaq Datën e Fillimit", + "start-date": "Data e fillimit", + "end-date": "Data e përfundimit", + "hide-start-date": "Fshih datën e fillimit", + "show-start-date": "Shfaq datën e fillimit", "hours": "Orë", "minutes": "Minuta", - "progressValue": "Vlera e Progresit", - "progressValueTooltip": "Vendosni përqindjen e progresit (0-100%)", + "progressValue": "Vlera e progresit", + "progressValueTooltip": "Vendos përqindjen e progresit (0-100%)", "progressValueRequired": "Ju lutemi vendosni një vlerë progresi", "progressValueRange": "Progresi duhet të jetë midis 0 dhe 100", - "taskWeight": "Pesha e Detyrës", - "taskWeightTooltip": "Vendosni peshën e kësaj nëndetyre (përqindje)", + "taskWeight": "Pesha e detyrës", + "taskWeightTooltip": "Vendos peshën e kësaj nëndetyre (përqindje)", "taskWeightRequired": "Ju lutemi vendosni një peshë detyre", "taskWeightRange": "Pesha duhet të jetë midis 0 dhe 100", - "recurring": "E Përsëritur" + "recurring": "Përsëritëse" }, "labels": { "labelInputPlaceholder": "Kërko ose krijo", @@ -43,71 +49,71 @@ }, "subTasks": { "title": "Nëndetyrat", - "addSubTask": "Shto Nëndetyrë", + "addSubTask": "Shto nëndetyrë", "addSubTaskInputPlaceholder": "Shkruani detyrën tuaj dhe shtypni enter", - "refreshSubTasks": "Rifresko Nëndetyrat", - "edit": "Modifiko", + "refreshSubTasks": "Rifresko nëndetyrat", + "edit": "Redakto", "delete": "Fshi", - "confirmDeleteSubTask": "Jeni i sigurt që doni të fshini këtë nëndetyrë?", - "deleteSubTask": "Fshi Nëndetyrën" + "confirmDeleteSubTask": "Jeni i sigurt që dëshironi ta fshini këtë nëndetyrë?", + "deleteSubTask": "Fshi nëndetyrën" }, "dependencies": { "title": "Varësitë", "addDependency": "+ Shto varësi të re", "blockedBy": "Bllokuar nga", - "searchTask": "Shkruani për të kërkuar detyrë", + "searchTask": "Shkruaj për të kërkuar detyrën", "noTasksFound": "Nuk u gjetën detyra", - "confirmDeleteDependency": "Jeni i sigurt që doni të fshini?" + "confirmDeleteDependency": "Jeni i sigurt që dëshironi ta fshini?" }, "attachments": { "title": "Bashkëngjitjet", - "chooseOrDropFileToUpload": "Zgjidhni ose hidhni skedar për të ngarkuar", + "chooseOrDropFileToUpload": "Zgjidh ose lësho skedarin për ta ngarkuar", "uploading": "Duke ngarkuar..." }, "comments": { "title": "Komentet", "addComment": "+ Shto koment të ri", - "noComments": "Ende pa komente. Bëhu i pari që komenton!", + "noComments": "Ende pa komente. Bëhu i pari që komentoni!", "delete": "Fshi", - "confirmDeleteComment": "Jeni i sigurt që doni të fshini këtë koment?", + "confirmDeleteComment": "Jeni i sigurt që dëshironi ta fshini këtë koment?", "addCommentPlaceholder": "Shto një koment...", "cancel": "Anulo", "commentButton": "Komento", "attachFiles": "Bashkëngjit skedarë", "addMoreFiles": "Shto më shumë skedarë", - "selectedFiles": "Skedarët e Zgjedhur (Deri në 25MB, Maksimumi {count})", - "maxFilesError": "Mund të ngarkoni maksimum {count} skedarë", - "processFilesError": "Dështoi përpunimi i skedarëve", + "selectedFiles": "Skedarët e zgjedhur (Deri në 25MB, Maksimumi {count})", + "maxFilesError": "Mund të ngarkoni maksimumi {count} skedarë", + "processFilesError": "Dështoi në përpunimin e skedarëve", "addCommentError": "Ju lutemi shtoni një koment ose bashkëngjitni skedarë", "createdBy": "Krijuar {{time}} nga {{user}}", "updatedTime": "Përditësuar {{time}}" }, "searchInputPlaceholder": "Kërko sipas emrit", - "pendingInvitation": "Ftesë në Pritje" + "pendingInvitation": "Ftesë në pritje" }, "taskTimeLogTab": { - "title": "Regjistri i Kohës", - "addTimeLog": "Shto regjistrim të ri kohe", - "totalLogged": "Totali i Regjistruar", + "title": "Regjistri i kohës", + "addTimeLog": "Shto regjistër të ri kohe", + "totalLogged": "Totali i regjistruar", "exportToExcel": "Eksporto në Excel", - "noTimeLogsFound": "Nuk u gjetën regjistra kohe", + "noTimeLogsFound": "Nuk u gjetën regjistrime kohe", "timeLogForm": { "date": "Data", - "startTime": "Koha e Fillimit", - "endTime": "Koha e Përfundimit", - "workDescription": "Përshkrimi i Punës", + "startTime": "Ora e fillimit", + "endTime": "Ora e përfundimit", + "workDescription": "Përshkrimi i punës", "descriptionPlaceholder": "Shto një përshkrim", "logTime": "Regjistro kohën", "updateTime": "Përditëso kohën", "cancel": "Anulo", "selectDateError": "Ju lutemi zgjidhni një datë", - "selectStartTimeError": "Ju lutemi zgjidhni kohën e fillimit", - "selectEndTimeError": "Ju lutemi zgjidhni kohën e përfundimit", - "endTimeAfterStartError": "Koha e përfundimit duhet të jetë pas kohës së fillimit" + "selectStartTimeError": "Ju lutemi zgjidhni orën e fillimit", + "selectEndTimeError": "Ju lutemi zgjidhni orën e përfundimit", + "endTimeAfterStartError": "Ora e përfundimit duhet të jetë pas orës së fillimit" } }, "taskActivityLogTab": { - "title": "Regjistri i Aktivitetit", + "title": "Regjistri i aktivitetit", "add": "SHTO", "remove": "HIQE", "none": "Asnjë", @@ -115,9 +121,9 @@ "createdTask": "krijoi detyrën." }, "taskProgress": { - "markAsDoneTitle": "Shëno Detyrën si të Kryer?", - "confirmMarkAsDone": "Po, shëno si të kryer", - "cancelMarkAsDone": "Jo, mbaj statusin aktual", - "markAsDoneDescription": "Keni vendosur progresin në 100%. Doni të përditësoni statusin e detyrës në \"Kryer\"?" + "markAsDoneTitle": "Shëno detyrën si të përfunduar?", + "confirmMarkAsDone": "Po, shënoje si të përfunduar", + "cancelMarkAsDone": "Jo, mbaj gjendjen aktuale", + "markAsDoneDescription": "Keni vendosur progresin në 100%. Dëshironi ta përditësoni gjendjen e detyrës në \"Përfunduar\"?" } } diff --git a/worklenz-frontend/public/locales/alb/task-list-table.json b/worklenz-frontend/public/locales/alb/task-list-table.json index 7e3f83dd..c009e734 100644 --- a/worklenz-frontend/public/locales/alb/task-list-table.json +++ b/worklenz-frontend/public/locales/alb/task-list-table.json @@ -39,6 +39,7 @@ "addTaskText": "Shto Detyrë", "addSubTaskText": "+ Shto Nën-Detyrë", "noTasksInGroup": "Nuk ka detyra në këtë grup", + "dropTaskHere": "Lëshoje detyrën këtu", "addTaskInputPlaceholder": "Shkruaj detyrën dhe shtyp Enter", "openButton": "Hap", diff --git a/worklenz-frontend/public/locales/de/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/de/task-drawer/task-drawer.json index 62e3f881..4bbc2559 100644 --- a/worklenz-frontend/public/locales/de/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/de/task-drawer/task-drawer.json @@ -1,22 +1,28 @@ { "taskHeader": { "taskNamePlaceholder": "Geben Sie Ihre Aufgabe ein", - "deleteTask": "Aufgabe löschen" + "deleteTask": "Aufgabe löschen", + "parentTask": "Übergeordnete Aufgabe", + "currentTask": "Aktuelle Aufgabe", + "back": "Zurück", + "backToParent": "Zurück zur übergeordneten Aufgabe", + "toParentTask": "zur übergeordneten Aufgabe", + "loadingHierarchy": "Hierarchie wird geladen..." }, "taskInfoTab": { "title": "Info", "details": { "title": "Details", - "task-key": "Aufgaben-Schlüssel", + "task-key": "Aufgabenschlüssel", "phase": "Phase", - "assignees": "Beauftragte", + "assignees": "Zugewiesene", "due-date": "Fälligkeitsdatum", "time-estimation": "Zeitschätzung", "priority": "Priorität", "labels": "Labels", "billable": "Abrechenbar", "notify": "Benachrichtigen", - "when-done-notify": "Bei Abschluss benachrichtigen", + "when-done-notify": "Bei Fertigstellung benachrichtigen", "start-date": "Startdatum", "end-date": "Enddatum", "hide-start-date": "Startdatum ausblenden", @@ -24,50 +30,50 @@ "hours": "Stunden", "minutes": "Minuten", "progressValue": "Fortschrittswert", - "progressValueTooltip": "Fortschritt in Prozent einstellen (0-100%)", + "progressValueTooltip": "Setzen Sie den Fortschrittsprozentsatz (0-100%)", "progressValueRequired": "Bitte geben Sie einen Fortschrittswert ein", "progressValueRange": "Fortschritt muss zwischen 0 und 100 liegen", "taskWeight": "Aufgabengewicht", - "taskWeightTooltip": "Gewicht dieser Teilaufgabe festlegen (Prozent)", + "taskWeightTooltip": "Setzen Sie das Gewicht dieser Unteraufgabe (Prozentsatz)", "taskWeightRequired": "Bitte geben Sie ein Aufgabengewicht ein", "taskWeightRange": "Gewicht muss zwischen 0 und 100 liegen", "recurring": "Wiederkehrend" }, "labels": { "labelInputPlaceholder": "Suchen oder erstellen", - "labelsSelectorInputTip": "Enter drücken zum Erstellen" + "labelsSelectorInputTip": "Drücken Sie Enter zum Erstellen" }, "description": { "title": "Beschreibung", - "placeholder": "Detailliertere Beschreibung hinzufügen..." + "placeholder": "Fügen Sie eine detailliertere Beschreibung hinzu..." }, "subTasks": { - "title": "Teilaufgaben", - "addSubTask": "Teilaufgabe hinzufügen", + "title": "Unteraufgaben", + "addSubTask": "Unteraufgabe hinzufügen", "addSubTaskInputPlaceholder": "Geben Sie Ihre Aufgabe ein und drücken Sie Enter", - "refreshSubTasks": "Teilaufgaben aktualisieren", + "refreshSubTasks": "Unteraufgaben aktualisieren", "edit": "Bearbeiten", "delete": "Löschen", - "confirmDeleteSubTask": "Sind Sie sicher, dass Sie diese Teilaufgabe löschen möchten?", - "deleteSubTask": "Teilaufgabe löschen" + "confirmDeleteSubTask": "Sind Sie sicher, dass Sie diese Unteraufgabe löschen möchten?", + "deleteSubTask": "Unteraufgabe löschen" }, "dependencies": { "title": "Abhängigkeiten", "addDependency": "+ Neue Abhängigkeit hinzufügen", "blockedBy": "Blockiert von", - "searchTask": "Aufgabe suchen", + "searchTask": "Zum Suchen der Aufgabe eingeben", "noTasksFound": "Keine Aufgaben gefunden", "confirmDeleteDependency": "Sind Sie sicher, dass Sie löschen möchten?" }, "attachments": { "title": "Anhänge", - "chooseOrDropFileToUpload": "Datei zum Hochladen wählen oder ablegen", + "chooseOrDropFileToUpload": "Datei zum Hochladen auswählen oder ablegen", "uploading": "Wird hochgeladen..." }, "comments": { "title": "Kommentare", "addComment": "+ Neuen Kommentar hinzufügen", - "noComments": "Noch keine Kommentare. Seien Sie der Erste!", + "noComments": "Noch keine Kommentare. Seien Sie der Erste, der kommentiert!", "delete": "Löschen", "confirmDeleteComment": "Sind Sie sicher, dass Sie diesen Kommentar löschen möchten?", "addCommentPlaceholder": "Kommentar hinzufügen...", @@ -75,9 +81,9 @@ "commentButton": "Kommentieren", "attachFiles": "Dateien anhängen", "addMoreFiles": "Weitere Dateien hinzufügen", - "selectedFiles": "Ausgewählte Dateien (Bis zu 25MB, Maximum {count})", + "selectedFiles": "Ausgewählte Dateien (Bis zu 25MB, Maximum von {count})", "maxFilesError": "Sie können maximal {count} Dateien hochladen", - "processFilesError": "Fehler beim Verarbeiten der Dateien", + "processFilesError": "Dateien konnten nicht verarbeitet werden", "addCommentError": "Bitte fügen Sie einen Kommentar hinzu oder hängen Sie Dateien an", "createdBy": "Erstellt {{time}} von {{user}}", "updatedTime": "Aktualisiert {{time}}" @@ -86,18 +92,18 @@ "pendingInvitation": "Ausstehende Einladung" }, "taskTimeLogTab": { - "title": "Zeiterfassung", - "addTimeLog": "Neuen Zeiteintrag hinzufügen", - "totalLogged": "Gesamt erfasst", + "title": "Zeitprotokoll", + "addTimeLog": "Neues Zeitprotokoll hinzufügen", + "totalLogged": "Gesamt protokolliert", "exportToExcel": "Nach Excel exportieren", - "noTimeLogsFound": "Keine Zeiteinträge gefunden", + "noTimeLogsFound": "Keine Zeitprotokolle gefunden", "timeLogForm": { "date": "Datum", "startTime": "Startzeit", "endTime": "Endzeit", "workDescription": "Arbeitsbeschreibung", "descriptionPlaceholder": "Beschreibung hinzufügen", - "logTime": "Zeit erfassen", + "logTime": "Zeit protokollieren", "updateTime": "Zeit aktualisieren", "cancel": "Abbrechen", "selectDateError": "Bitte wählen Sie ein Datum", diff --git a/worklenz-frontend/public/locales/de/task-list-table.json b/worklenz-frontend/public/locales/de/task-list-table.json index 9c2ff314..23439a1b 100644 --- a/worklenz-frontend/public/locales/de/task-list-table.json +++ b/worklenz-frontend/public/locales/de/task-list-table.json @@ -40,6 +40,7 @@ "addSubTaskText": "+ Unteraufgabe hinzufügen", "addTaskInputPlaceholder": "Aufgabe eingeben und Enter drücken", "noTasksInGroup": "Keine Aufgaben in dieser Gruppe", + "dropTaskHere": "Aufgabe hier ablegen", "openButton": "Öffnen", "okButton": "OK", 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 b5147324..4aa0cfbb 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json @@ -1,7 +1,13 @@ { "taskHeader": { "taskNamePlaceholder": "Type your Task", - "deleteTask": "Delete Task" + "deleteTask": "Delete Task", + "parentTask": "Parent Task", + "currentTask": "Current Task", + "back": "Back", + "backToParent": "Back to Parent Task", + "toParentTask": "to parent task", + "loadingHierarchy": "Loading hierarchy..." }, "taskInfoTab": { "title": "Info", diff --git a/worklenz-frontend/public/locales/en/task-list-table.json b/worklenz-frontend/public/locales/en/task-list-table.json index 5c03f203..abd97ca5 100644 --- a/worklenz-frontend/public/locales/en/task-list-table.json +++ b/worklenz-frontend/public/locales/en/task-list-table.json @@ -40,6 +40,7 @@ "addSubTaskText": "Add Sub Task", "addTaskInputPlaceholder": "Type your task and hit enter", "noTasksInGroup": "No tasks in this group", + "dropTaskHere": "Drop task here", "openButton": "Open", "okButton": "Ok", 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 8e438716..df20b57b 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json @@ -1,35 +1,41 @@ { "taskHeader": { - "taskNamePlaceholder": "Escriba su Tarea", - "deleteTask": "Eliminar Tarea" + "taskNamePlaceholder": "Escribe tu tarea", + "deleteTask": "Eliminar tarea", + "parentTask": "Tarea principal", + "currentTask": "Tarea actual", + "back": "Volver", + "backToParent": "Volver a la tarea principal", + "toParentTask": "a la tarea principal", + "loadingHierarchy": "Cargando jerarquía..." }, "taskInfoTab": { "title": "Información", "details": { "title": "Detalles", - "task-key": "Clave de Tarea", + "task-key": "Clave de tarea", "phase": "Fase", "assignees": "Asignados", - "due-date": "Fecha de Vencimiento", - "time-estimation": "Estimación de Tiempo", + "due-date": "Fecha de vencimiento", + "time-estimation": "Estimación de tiempo", "priority": "Prioridad", "labels": "Etiquetas", "billable": "Facturable", "notify": "Notificar", - "when-done-notify": "Al terminar, notificar", - "start-date": "Fecha de Inicio", - "end-date": "Fecha de Fin", - "hide-start-date": "Ocultar Fecha de Inicio", - "show-start-date": "Mostrar Fecha de Inicio", + "when-done-notify": "Al finalizar, notificar", + "start-date": "Fecha de inicio", + "end-date": "Fecha de finalización", + "hide-start-date": "Ocultar fecha de inicio", + "show-start-date": "Mostrar fecha de inicio", "hours": "Horas", "minutes": "Minutos", - "progressValue": "Valor de Progreso", + "progressValue": "Valor de progreso", "progressValueTooltip": "Establecer el porcentaje de progreso (0-100%)", - "progressValueRequired": "Por favor, introduzca un valor de progreso", + "progressValueRequired": "Por favor ingrese un valor de progreso", "progressValueRange": "El progreso debe estar entre 0 y 100", - "taskWeight": "Peso de la Tarea", + "taskWeight": "Peso de la tarea", "taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)", - "taskWeightRequired": "Por favor, introduzca un peso de tarea", + "taskWeightRequired": "Por favor ingrese un peso de tarea", "taskWeightRange": "El peso debe estar entre 0 y 100", "recurring": "Recurrente" }, @@ -39,85 +45,85 @@ }, "description": { "title": "Descripción", - "placeholder": "Añadir una descripción más detallada..." + "placeholder": "Añade una descripción más detallada..." }, "subTasks": { - "title": "Sub Tareas", - "addSubTask": "Agregar Sub Tarea", - "addSubTaskInputPlaceholder": "Escriba su tarea y presione enter", - "refreshSubTasks": "Actualizar Sub Tareas", + "title": "Subtareas", + "addSubTask": "Añadir subtarea", + "addSubTaskInputPlaceholder": "Escribe tu tarea y presiona enter", + "refreshSubTasks": "Actualizar subtareas", "edit": "Editar", "delete": "Eliminar", - "confirmDeleteSubTask": "¿Está seguro de que desea eliminar esta subtarea?", - "deleteSubTask": "Eliminar Sub Tarea" + "confirmDeleteSubTask": "¿Estás seguro de que quieres eliminar esta subtarea?", + "deleteSubTask": "Eliminar subtarea" }, "dependencies": { "title": "Dependencias", - "addDependency": "+ Agregar nueva dependencia", + "addDependency": "+ Añadir nueva dependencia", "blockedBy": "Bloqueado por", - "searchTask": "Escribir para buscar tarea", + "searchTask": "Escribe para buscar tarea", "noTasksFound": "No se encontraron tareas", - "confirmDeleteDependency": "¿Está seguro de que desea eliminar?" + "confirmDeleteDependency": "¿Estás seguro de que quieres eliminar?" }, "attachments": { "title": "Adjuntos", - "chooseOrDropFileToUpload": "Elija o arrastre un archivo para subir", + "chooseOrDropFileToUpload": "Elige o arrastra archivo para subir", "uploading": "Subiendo..." }, "comments": { "title": "Comentarios", - "addComment": "+ Agregar nuevo comentario", + "addComment": "+ Añadir nuevo comentario", "noComments": "Aún no hay comentarios. ¡Sé el primero en comentar!", "delete": "Eliminar", - "confirmDeleteComment": "¿Está seguro de que desea eliminar este comentario?", - "addCommentPlaceholder": "Agregar un comentario...", + "confirmDeleteComment": "¿Estás seguro de que quieres eliminar este comentario?", + "addCommentPlaceholder": "Añadir un comentario...", "cancel": "Cancelar", "commentButton": "Comentar", "attachFiles": "Adjuntar archivos", - "addMoreFiles": "Agregar más archivos", - "selectedFiles": "Archivos Seleccionados (Hasta 25MB, Máximo {count})", - "maxFilesError": "Solo puede subir un máximo de {count} archivos", + "addMoreFiles": "Añadir más archivos", + "selectedFiles": "Archivos seleccionados (Hasta 25MB, Máximo de {count})", + "maxFilesError": "Solo puedes subir un máximo de {count} archivos", "processFilesError": "Error al procesar archivos", - "addCommentError": "Por favor agregue un comentario o adjunte archivos", + "addCommentError": "Por favor añade un comentario o adjunta archivos", "createdBy": "Creado {{time}} por {{user}}", "updatedTime": "Actualizado {{time}}" }, "searchInputPlaceholder": "Buscar por nombre", - "pendingInvitation": "Invitación Pendiente" + "pendingInvitation": "Invitación pendiente" }, "taskTimeLogTab": { - "title": "Registro de Tiempo", + "title": "Registro de tiempo", "addTimeLog": "Añadir nuevo registro de tiempo", - "totalLogged": "Total Registrado", + "totalLogged": "Total registrado", "exportToExcel": "Exportar a Excel", "noTimeLogsFound": "No se encontraron registros de tiempo", "timeLogForm": { "date": "Fecha", - "startTime": "Hora de Inicio", - "endTime": "Hora de Fin", - "workDescription": "Descripción del Trabajo", - "descriptionPlaceholder": "Agregar una descripción", + "startTime": "Hora de inicio", + "endTime": "Hora de finalización", + "workDescription": "Descripción del trabajo", + "descriptionPlaceholder": "Añadir una descripción", "logTime": "Registrar tiempo", "updateTime": "Actualizar tiempo", "cancel": "Cancelar", - "selectDateError": "Por favor seleccione una fecha", - "selectStartTimeError": "Por favor seleccione la hora de inicio", - "selectEndTimeError": "Por favor seleccione la hora de fin", - "endTimeAfterStartError": "La hora de fin debe ser posterior a la hora de inicio" + "selectDateError": "Por favor selecciona una fecha", + "selectStartTimeError": "Por favor selecciona hora de inicio", + "selectEndTimeError": "Por favor selecciona hora de finalización", + "endTimeAfterStartError": "La hora de finalización debe ser posterior a la de inicio" } }, "taskActivityLogTab": { - "title": "Registro de Actividad", - "add": "AGREGAR", - "remove": "QUITAR", + "title": "Registro de actividad", + "add": "AÑADIR", + "remove": "ELIMINAR", "none": "Ninguno", "weight": "Peso", "createdTask": "creó la tarea." }, "taskProgress": { - "markAsDoneTitle": "¿Marcar Tarea como Completada?", + "markAsDoneTitle": "¿Marcar tarea como completada?", "confirmMarkAsDone": "Sí, marcar como completada", "cancelMarkAsDone": "No, mantener estado actual", - "markAsDoneDescription": "Ha establecido el progreso al 100%. ¿Le gustaría actualizar el estado de la tarea a \"Completada\"?" + "markAsDoneDescription": "Has establecido el progreso al 100%. ¿Te gustaría actualizar el estado de la tarea a \"Completada\"?" } } diff --git a/worklenz-frontend/public/locales/es/task-list-table.json b/worklenz-frontend/public/locales/es/task-list-table.json index 0648c2ff..779c76ed 100644 --- a/worklenz-frontend/public/locales/es/task-list-table.json +++ b/worklenz-frontend/public/locales/es/task-list-table.json @@ -39,6 +39,7 @@ "addTaskText": "Agregar tarea", "addSubTaskText": "Agregar subtarea", "noTasksInGroup": "No hay tareas en este grupo", + "dropTaskHere": "Soltar tarea aquí", "addTaskInputPlaceholder": "Escribe tu tarea y presiona enter", "openButton": "Abrir", 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 c24e943e..a2fe12c3 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json @@ -1,33 +1,39 @@ { "taskHeader": { - "taskNamePlaceholder": "Digite sua Tarefa", - "deleteTask": "Deletar Tarefa" + "taskNamePlaceholder": "Digite sua tarefa", + "deleteTask": "Excluir tarefa", + "parentTask": "Tarefa principal", + "currentTask": "Tarefa atual", + "back": "Voltar", + "backToParent": "Voltar à tarefa principal", + "toParentTask": "à tarefa principal", + "loadingHierarchy": "Carregando hierarquia..." }, "taskInfoTab": { "title": "Informações", "details": { "title": "Detalhes", - "task-key": "Chave da Tarefa", + "task-key": "Chave da tarefa", "phase": "Fase", "assignees": "Responsáveis", - "due-date": "Data de Vencimento", - "time-estimation": "Estimativa de Tempo", + "due-date": "Data de vencimento", + "time-estimation": "Estimativa de tempo", "priority": "Prioridade", "labels": "Etiquetas", "billable": "Faturável", "notify": "Notificar", - "when-done-notify": "Quando concluído, notificar", - "start-date": "Data de Início", - "end-date": "Data de Fim", - "hide-start-date": "Ocultar Data de Início", - "show-start-date": "Mostrar Data de Início", + "when-done-notify": "Ao concluir, notificar", + "start-date": "Data de início", + "end-date": "Data de término", + "hide-start-date": "Ocultar data de início", + "show-start-date": "Mostrar data de início", "hours": "Horas", "minutes": "Minutos", - "progressValue": "Valor do Progresso", + "progressValue": "Valor do 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", + "taskWeight": "Peso da tarefa", "taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)", "taskWeightRequired": "Por favor, insira um peso da tarefa", "taskWeightRange": "O peso deve estar entre 0 e 100", @@ -39,17 +45,17 @@ }, "description": { "title": "Descrição", - "placeholder": "Adicionar uma descrição mais detalhada..." + "placeholder": "Adicione uma descrição mais detalhada..." }, "subTasks": { - "title": "Sub Tarefas", - "addSubTask": "Adicionar Sub Tarefa", + "title": "Subtarefas", + "addSubTask": "Adicionar subtarefa", "addSubTaskInputPlaceholder": "Digite sua tarefa e pressione enter", - "refreshSubTasks": "Atualizar Sub Tarefas", + "refreshSubTasks": "Atualizar subtarefas", "edit": "Editar", - "delete": "Deletar", - "confirmDeleteSubTask": "Tem certeza de que deseja deletar esta subtarefa?", - "deleteSubTask": "Deletar Sub Tarefa" + "delete": "Excluir", + "confirmDeleteSubTask": "Tem certeza de que deseja excluir esta subtarefa?", + "deleteSubTask": "Excluir subtarefa" }, "dependencies": { "title": "Dependências", @@ -57,57 +63,57 @@ "blockedBy": "Bloqueado por", "searchTask": "Digite para pesquisar tarefa", "noTasksFound": "Nenhuma tarefa encontrada", - "confirmDeleteDependency": "Tem certeza de que deseja deletar?" + "confirmDeleteDependency": "Tem certeza de que deseja excluir?" }, "attachments": { "title": "Anexos", - "chooseOrDropFileToUpload": "Escolha ou arraste um arquivo para upload", + "chooseOrDropFileToUpload": "Escolha ou arraste arquivo para enviar", "uploading": "Enviando..." }, "comments": { "title": "Comentários", "addComment": "+ Adicionar novo comentário", "noComments": "Ainda não há comentários. Seja o primeiro a comentar!", - "delete": "Deletar", - "confirmDeleteComment": "Tem certeza de que deseja deletar este comentário?", + "delete": "Excluir", + "confirmDeleteComment": "Tem certeza de que deseja excluir este comentário?", "addCommentPlaceholder": "Adicionar um comentário...", "cancel": "Cancelar", "commentButton": "Comentar", "attachFiles": "Anexar arquivos", "addMoreFiles": "Adicionar mais arquivos", - "selectedFiles": "Arquivos Selecionados (Até 25MB, Máximo {count})", - "maxFilesError": "Você pode fazer upload de no máximo {count} arquivos", + "selectedFiles": "Arquivos selecionados (Até 25MB, Máximo de {count})", + "maxFilesError": "Você pode enviar no máximo {count} arquivos", "processFilesError": "Falha ao processar arquivos", - "addCommentError": "Por favor adicione um comentário ou anexe arquivos", + "addCommentError": "Por favor, adicione um comentário ou anexe arquivos", "createdBy": "Criado {{time}} por {{user}}", "updatedTime": "Atualizado {{time}}" }, "searchInputPlaceholder": "Pesquisar por nome", - "pendingInvitation": "Convite Pendente" + "pendingInvitation": "Convite pendente" }, "taskTimeLogTab": { - "title": "Registro de Tempo", + "title": "Registro de tempo", "addTimeLog": "Adicionar novo registro de tempo", - "totalLogged": "Total Registrado", + "totalLogged": "Total registrado", "exportToExcel": "Exportar para Excel", "noTimeLogsFound": "Nenhum registro de tempo encontrado", "timeLogForm": { "date": "Data", - "startTime": "Hora de Início", - "endTime": "Hora de Fim", - "workDescription": "Descrição do Trabalho", + "startTime": "Hora de início", + "endTime": "Hora de término", + "workDescription": "Descrição do trabalho", "descriptionPlaceholder": "Adicionar uma descrição", "logTime": "Registrar tempo", "updateTime": "Atualizar tempo", "cancel": "Cancelar", - "selectDateError": "Por favor selecione uma data", - "selectStartTimeError": "Por favor selecione a hora de início", - "selectEndTimeError": "Por favor selecione a hora de fim", - "endTimeAfterStartError": "A hora de fim deve ser posterior à hora de início" + "selectDateError": "Por favor, selecione uma data", + "selectStartTimeError": "Por favor, selecione a hora de início", + "selectEndTimeError": "Por favor, selecione a hora de término", + "endTimeAfterStartError": "A hora de término deve ser posterior à hora de início" } }, "taskActivityLogTab": { - "title": "Registro de Atividade", + "title": "Registro de atividade", "add": "ADICIONAR", "remove": "REMOVER", "none": "Nenhum", @@ -115,7 +121,7 @@ "createdTask": "criou a tarefa." }, "taskProgress": { - "markAsDoneTitle": "Marcar Tarefa como Concluída?", + "markAsDoneTitle": "Marcar tarefa como concluída?", "confirmMarkAsDone": "Sim, marcar como concluída", "cancelMarkAsDone": "Não, manter status atual", "markAsDoneDescription": "Você definiu o progresso para 100%. Gostaria de atualizar o status da tarefa para \"Concluída\"?" diff --git a/worklenz-frontend/public/locales/pt/task-list-table.json b/worklenz-frontend/public/locales/pt/task-list-table.json index f53d834f..54fd2a33 100644 --- a/worklenz-frontend/public/locales/pt/task-list-table.json +++ b/worklenz-frontend/public/locales/pt/task-list-table.json @@ -39,6 +39,7 @@ "addTaskText": "Adicionar Tarefa", "addSubTaskText": "+ Adicionar Subtarefa", "noTasksInGroup": "Nenhuma tarefa neste grupo", + "dropTaskHere": "Soltar tarefa aqui", "addTaskInputPlaceholder": "Digite sua tarefa e pressione enter", "openButton": "Abrir", diff --git a/worklenz-frontend/public/locales/zh/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/zh/task-drawer/task-drawer.json index dfe304fe..868b2876 100644 --- a/worklenz-frontend/public/locales/zh/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/zh/task-drawer/task-drawer.json @@ -1,22 +1,28 @@ { "taskHeader": { "taskNamePlaceholder": "输入您的任务", - "deleteTask": "删除任务" + "deleteTask": "删除任务", + "parentTask": "父任务", + "currentTask": "当前任务", + "back": "返回", + "backToParent": "返回父任务", + "toParentTask": "到父任务", + "loadingHierarchy": "加载层次结构..." }, "taskInfoTab": { "title": "信息", "details": { - "title": "详情", + "title": "详细信息", "task-key": "任务键", "phase": "阶段", - "assignees": "受让人", + "assignees": "受理人", "due-date": "截止日期", "time-estimation": "时间估算", "priority": "优先级", "labels": "标签", "billable": "可计费", "notify": "通知", - "when-done-notify": "完成时,通知", + "when-done-notify": "完成时通知", "start-date": "开始日期", "end-date": "结束日期", "hide-start-date": "隐藏开始日期", @@ -24,18 +30,18 @@ "hours": "小时", "minutes": "分钟", "progressValue": "进度值", - "progressValueTooltip": "设置进度百分比(0-100%)", + "progressValueTooltip": "设置进度百分比 (0-100%)", "progressValueRequired": "请输入进度值", - "progressValueRange": "进度必须在0到100之间", + "progressValueRange": "进度必须在 0 到 100 之间", "taskWeight": "任务权重", - "taskWeightTooltip": "设置此子任务的权重(百分比)", + "taskWeightTooltip": "设置此子任务的权重 (百分比)", "taskWeightRequired": "请输入任务权重", - "taskWeightRange": "权重必须在0到100之间", + "taskWeightRange": "权重必须在 0 到 100 之间", "recurring": "重复" }, "labels": { "labelInputPlaceholder": "搜索或创建", - "labelsSelectorInputTip": "按回车创建" + "labelsSelectorInputTip": "按 Enter 键创建" }, "description": { "title": "描述", @@ -44,7 +50,7 @@ "subTasks": { "title": "子任务", "addSubTask": "添加子任务", - "addSubTaskInputPlaceholder": "输入您的任务并按回车", + "addSubTaskInputPlaceholder": "输入您的任务并按回车键", "refreshSubTasks": "刷新子任务", "edit": "编辑", "delete": "删除", @@ -52,10 +58,10 @@ "deleteSubTask": "删除子任务" }, "dependencies": { - "title": "依赖关系", - "addDependency": "+ 添加新依赖", + "title": "依赖项", + "addDependency": "+ 添加新依赖项", "blockedBy": "被阻止", - "searchTask": "输入搜索任务", + "searchTask": "输入以搜索任务", "noTasksFound": "未找到任务", "confirmDeleteDependency": "您确定要删除吗?" }, @@ -67,7 +73,7 @@ "comments": { "title": "评论", "addComment": "+ 添加新评论", - "noComments": "还没有评论。成为第一个评论的人!", + "noComments": "还没有评论。成为第一个评论者!", "delete": "删除", "confirmDeleteComment": "您确定要删除此评论吗?", "addCommentPlaceholder": "添加评论...", @@ -75,12 +81,12 @@ "commentButton": "评论", "attachFiles": "附加文件", "addMoreFiles": "添加更多文件", - "selectedFiles": "已选择的文件(最多25MB,最大{count}个)", - "maxFilesError": "您最多只能上传{count}个文件", + "selectedFiles": "选定文件 (最多 25MB,最多 {count} 个)", + "maxFilesError": "您最多只能上传 {count} 个文件", "processFilesError": "处理文件失败", "addCommentError": "请添加评论或附加文件", - "createdBy": "{{time}}由{{user}}创建", - "updatedTime": "更新于{{time}}" + "createdBy": "由 {{user}} 在 {{time}} 创建", + "updatedTime": "更新于 {{time}}" }, "searchInputPlaceholder": "按名称搜索", "pendingInvitation": "待处理邀请" @@ -88,8 +94,8 @@ "taskTimeLogTab": { "title": "时间日志", "addTimeLog": "添加新时间日志", - "totalLogged": "总记录时间", - "exportToExcel": "导出到Excel", + "totalLogged": "总计记录", + "exportToExcel": "导出到 Excel", "noTimeLogsFound": "未找到时间日志", "timeLogForm": { "date": "日期", @@ -103,7 +109,7 @@ "selectDateError": "请选择日期", "selectStartTimeError": "请选择开始时间", "selectEndTimeError": "请选择结束时间", - "endTimeAfterStartError": "结束时间必须在开始时间之后" + "endTimeAfterStartError": "结束时间必须晚于开始时间" } }, "taskActivityLogTab": { @@ -116,8 +122,8 @@ }, "taskProgress": { "markAsDoneTitle": "将任务标记为完成?", - "confirmMarkAsDone": "是的,标记为完成", - "cancelMarkAsDone": "不,保持当前状态", - "markAsDoneDescription": "您已将进度设置为100%。您想将任务状态更新为\"完成\"吗?" + "confirmMarkAsDone": "是,标记为完成", + "cancelMarkAsDone": "否,保持当前状态", + "markAsDoneDescription": "您已将进度设置为 100%。您想将任务状态更新为\"完成\"吗?" } } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/task-list-table.json b/worklenz-frontend/public/locales/zh/task-list-table.json index f3ec040f..63718830 100644 --- a/worklenz-frontend/public/locales/zh/task-list-table.json +++ b/worklenz-frontend/public/locales/zh/task-list-table.json @@ -37,6 +37,7 @@ "addSubTaskText": "+ 添加子任务", "addTaskInputPlaceholder": "输入任务并按回车键", "noTasksInGroup": "此组中没有任务", + "dropTaskHere": "将任务拖到这里", "openButton": "打开", "okButton": "确定", "noLabelsFound": "未找到标签", diff --git a/worklenz-frontend/src/components/CustomColordLabel.tsx b/worklenz-frontend/src/components/CustomColordLabel.tsx index 068907f0..83c25281 100644 --- a/worklenz-frontend/src/components/CustomColordLabel.tsx +++ b/worklenz-frontend/src/components/CustomColordLabel.tsx @@ -39,7 +39,7 @@ const CustomColordLabel = React.forwardRef {namesString} diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-phase-selector/task-drawer-phase-selector.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-phase-selector/task-drawer-phase-selector.tsx index b336f091..e7504761 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-phase-selector/task-drawer-phase-selector.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-phase-selector/task-drawer-phase-selector.tsx @@ -1,6 +1,5 @@ import { useSocket } from '@/socket/socketContext'; import { ITaskPhase } from '@/types/tasks/taskPhase.types'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { Select } from 'antd'; import { Form } from 'antd'; @@ -27,12 +26,6 @@ const TaskDrawerPhaseSelector = ({ phases, task }: TaskDrawerPhaseSelectorProps) phase_id: value, parent_task: task.parent_task_id || null, }); - - // socket?.once(SocketEvents.TASK_PHASE_CHANGE.toString(), () => { - // if(list.getCurrentGroup().value === this.list.GROUP_BY_PHASE_VALUE && this.list.isSubtasksIncluded) { - // this.list.emitRefreshSubtasksIncluded(); - // } - // }); }; return ( @@ -41,8 +34,11 @@ const TaskDrawerPhaseSelector = ({ phases, task }: TaskDrawerPhaseSelectorProps) allowClear placeholder="Select Phase" options={phaseMenuItems} - style={{ width: 'fit-content' }} - dropdownStyle={{ width: 'fit-content' }} + styles={{ + root: { + width: 'fit-content', + }, + }} onChange={handlePhaseChange} /> diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.css b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.css index b2a7f42a..b7fe31a0 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.css +++ b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.css @@ -7,3 +7,28 @@ outline: 1px solid #d9d9d9; border-radius: 4px; } + +/* Task name display styles */ +.task-name-display { + margin: 0; + padding: 2px 8px; + font-size: 16px; + cursor: pointer; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-height: 20px; + line-height: 1.4; + display: flex; + align-items: center; +} + +.task-name-display:hover { + background-color: rgba(0, 0, 0, 0.02); + border-radius: 4px; +} + +[data-theme='dark'] .task-name-display:hover { + background-color: rgba(255, 255, 255, 0.05); +} diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx index 8096a8e8..40de65e8 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx @@ -1,4 +1,4 @@ -import { Button, Dropdown, Flex, Input, InputRef, MenuProps } from 'antd'; +import { Button, Dropdown, Flex, Input, InputRef, MenuProps, Tooltip } from 'antd'; import React, { ChangeEvent, useEffect, useRef, useState } from 'react'; import { EllipsisOutlined } from '@ant-design/icons'; import { TFunction } from 'i18next'; @@ -21,12 +21,19 @@ import { deleteBoardTask } from '@/features/board/board-slice'; import { deleteTask as deleteKanbanTask, updateEnhancedKanbanSubtask } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import useTabSearchParam from '@/hooks/useTabSearchParam'; import { ITaskViewModel } from '@/types/tasks/task.types'; +import TaskHierarchyBreadcrumb from '../task-hierarchy-breadcrumb/task-hierarchy-breadcrumb'; type TaskDrawerHeaderProps = { inputRef: React.RefObject; t: TFunction; }; +// Utility function to truncate text +const truncateText = (text: string, maxLength: number = 50): string => { + if (!text || text.length <= maxLength) return text; + return `${text.substring(0, maxLength)}...`; +}; + const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { const dispatch = useAppDispatch(); const { socket, connected } = useSocket(); @@ -38,6 +45,9 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { const [taskName, setTaskName] = useState(taskFormViewModel?.task?.name ?? ''); const currentSession = useAuthService().getCurrentSession(); + // Check if current task is a sub-task + const isSubTask = taskFormViewModel?.task?.is_sub_task || !!taskFormViewModel?.task?.parent_task_id; + useEffect(() => { setTaskName(taskFormViewModel?.task?.name ?? ''); }, [taskFormViewModel?.task?.name]); @@ -126,54 +136,57 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { // No need for local socket listeners that could interfere with global handlers }; + const displayTaskName = taskName || t('taskHeader.taskNamePlaceholder'); + const truncatedTaskName = truncateText(displayTaskName, 50); + const shouldShowTooltip = displayTaskName.length > 50; + return ( - - - {isEditing ? ( - onTaskNameChange(e)} - onBlur={handleInputBlur} - placeholder={t('taskHeader.taskNamePlaceholder')} - className="task-name-input" - style={{ - width: '100%', - border: 'none', - }} - showCount={true} - maxLength={250} - autoFocus - /> - ) : ( -

setIsEditing(true)} - style={{ - margin: 0, - padding: '4px 11px', - fontSize: '16px', - cursor: 'pointer', - wordWrap: 'break-word', - overflowWrap: 'break-word', - width: '100%', - }} - > - {taskName || t('taskHeader.taskNamePlaceholder')} -

- )} +
+ {/* Show breadcrumb for sub-tasks */} + {isSubTask && } + + + + {isEditing ? ( + onTaskNameChange(e)} + onBlur={handleInputBlur} + placeholder={t('taskHeader.taskNamePlaceholder')} + className="task-name-input" + style={{ + width: '100%', + border: 'none', + }} + showCount={true} + maxLength={250} + autoFocus + /> + ) : ( + +

setIsEditing(true)} + className="task-name-display" + > + {truncatedTaskName} +

+
+ )} +
+ + + + +
); }; diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer.tsx index 335ad133..de80fb4f 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer.tsx @@ -3,7 +3,7 @@ import Drawer from 'antd/es/drawer'; import { InputRef } from 'antd/es/input'; import { useTranslation } from 'react-i18next'; import { useEffect, useRef, useState } from 'react'; -import { PlusOutlined } from '@ant-design/icons'; +import { PlusOutlined, CloseOutlined, ArrowLeftOutlined } from '@ant-design/icons'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; @@ -13,6 +13,7 @@ import { setTaskFormViewModel, setTaskSubscribers, setTimeLogEditing, + fetchTask, } from '@/features/task-drawer/task-drawer.slice'; import './task-drawer.css'; @@ -33,6 +34,7 @@ const TaskDrawer = () => { const { showTaskDrawer, timeLogEditing } = useAppSelector(state => state.taskDrawerReducer); const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer); + const { projectId } = useAppSelector(state => state.projectReducer); const taskNameInputRef = useRef(null); const isClosingManually = useRef(false); @@ -54,6 +56,17 @@ const TaskDrawer = () => { dispatch(setTaskSubscribers([])); }; + const handleBackToParent = () => { + if (taskFormViewModel?.task?.parent_task_id && projectId) { + // Navigate to parent task + dispatch(setSelectedTaskId(taskFormViewModel.task.parent_task_id)); + dispatch(fetchTask({ + taskId: taskFormViewModel.task.parent_task_id, + projectId + })); + } + }; + const handleOnClose = ( e?: React.MouseEvent | React.KeyboardEvent ) => { @@ -68,10 +81,8 @@ const TaskDrawer = () => { if (isClickOutsideDrawer || !taskFormViewModel?.task?.is_sub_task) { resetTaskState(); } else { - dispatch(setSelectedTaskId(null)); - dispatch(setTaskFormViewModel({})); - dispatch(setTaskSubscribers([])); - dispatch(setSelectedTaskId(taskFormViewModel?.task?.parent_task_id || null)); + // For sub-tasks, navigate to parent instead of closing + handleBackToParent(); } // Reset the flag after a short delay setTimeout(() => { @@ -205,6 +216,17 @@ const TaskDrawer = () => { }; }; + // Check if current task is a sub-task + const isSubTask = taskFormViewModel?.task?.is_sub_task || !!taskFormViewModel?.task?.parent_task_id; + + // Custom close icon based on whether it's a sub-task + const getCloseIcon = () => { + if (isSubTask) { + return ; + } + return ; + }; + const drawerProps = { open: showTaskDrawer, onClose: handleOnClose, @@ -215,6 +237,7 @@ const TaskDrawer = () => { footer: renderFooter(), bodyStyle: getBodyStyle(), footerStyle: getFooterStyle(), + closeIcon: getCloseIcon(), }; return ( diff --git a/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.css b/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.css new file mode 100644 index 00000000..58f85ab6 --- /dev/null +++ b/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.css @@ -0,0 +1,88 @@ +.task-hierarchy-breadcrumb { + margin-bottom: 4px; +} + +.task-hierarchy-breadcrumb .ant-breadcrumb { + font-size: 14px; + line-height: 1; +} + +.task-hierarchy-breadcrumb .ant-breadcrumb-link { + color: inherit; +} + +.task-hierarchy-breadcrumb .ant-breadcrumb-separator { + color: #8c8c8c; + margin: 0 4px; +} + +/* Dark mode styles */ +[data-theme='dark'] .task-hierarchy-breadcrumb .ant-breadcrumb-separator { + color: #595959; +} + +/* Back button styles */ +.task-hierarchy-breadcrumb .ant-btn-link { + padding: 0; + height: auto; + font-size: 14px; + line-height: 1.3; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: flex; + align-items: center; +} + +.task-hierarchy-breadcrumb .ant-btn-link .anticon { + margin-right: 0; /* Remove default margin */ +} + +.task-hierarchy-breadcrumb .ant-btn-link:hover { + color: #40a9ff; +} + +[data-theme='dark'] .task-hierarchy-breadcrumb .ant-btn-link:hover { + color: #40a9ff; +} + +/* Current task name styles */ +.task-hierarchy-breadcrumb .current-task-name { + font-size: 14px; + color: #000000d9; + font-weight: 500; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + line-height: 1.3; +} + +[data-theme='dark'] .task-hierarchy-breadcrumb .current-task-name { + color: #ffffffd9; +} + +/* Breadcrumb item container */ +.task-hierarchy-breadcrumb .ant-breadcrumb-item { + max-width: 220px; + overflow: hidden; + display: flex; + align-items: center; +} + +/* Ensure breadcrumb items don't break the layout */ +.task-hierarchy-breadcrumb .ant-breadcrumb ol { + display: flex; + flex-wrap: wrap; + align-items: center; + margin: 0; + padding: 0; +} + +/* Better alignment for breadcrumb items */ +.task-hierarchy-breadcrumb .ant-breadcrumb-item .ant-breadcrumb-link { + display: flex; + align-items: center; +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.tsx b/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.tsx new file mode 100644 index 00000000..792b4347 --- /dev/null +++ b/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.tsx @@ -0,0 +1,189 @@ +import React, { useState, useEffect } from 'react'; +import { Breadcrumb, Button, Typography, Tooltip } from 'antd'; +import { HomeOutlined } from '@ant-design/icons'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { fetchTask, setSelectedTaskId } from '@/features/task-drawer/task-drawer.slice'; +import { tasksApiService } from '@/api/tasks/tasks.api.service'; +import { TFunction } from 'i18next'; +import './task-hierarchy-breadcrumb.css'; + +interface TaskHierarchyBreadcrumbProps { + t: TFunction; + onBackClick?: () => void; +} + +interface TaskHierarchyItem { + id: string; + name: string; + parent_task_id?: string; +} + +// Utility function to truncate text +const truncateText = (text: string, maxLength: number = 25): string => { + if (!text || text.length <= maxLength) return text; + return `${text.substring(0, maxLength)}...`; +}; + +const TaskHierarchyBreadcrumb: React.FC = ({ t, onBackClick }) => { + const dispatch = useAppDispatch(); + const { taskFormViewModel } = useAppSelector(state => state.taskDrawerReducer); + const { projectId } = useAppSelector(state => state.projectReducer); + const themeMode = useAppSelector(state => state.themeReducer.mode); + const [hierarchyPath, setHierarchyPath] = useState([]); + const [loading, setLoading] = useState(false); + + const task = taskFormViewModel?.task; + const isSubTask = task?.is_sub_task || !!task?.parent_task_id; + + // Recursively fetch the complete hierarchy path + const fetchHierarchyPath = async (currentTaskId: string): Promise => { + if (!projectId) return []; + + const path: TaskHierarchyItem[] = []; + let taskId = currentTaskId; + + // Traverse up the hierarchy until we reach the root + while (taskId) { + try { + const response = await tasksApiService.getFormViewModel(taskId, projectId); + if (response.done && response.body.task) { + const taskData = response.body.task; + path.unshift({ + id: taskData.id, + name: taskData.name || '', + parent_task_id: taskData.parent_task_id || undefined + }); + + // Move to parent task + taskId = taskData.parent_task_id || ''; + } else { + break; + } + } catch (error) { + console.error('Error fetching task in hierarchy:', error); + break; + } + } + + return path; + }; + + // Fetch the complete hierarchy when component mounts or task changes + useEffect(() => { + const loadHierarchy = async () => { + if (!isSubTask || !task?.parent_task_id || !projectId) { + setHierarchyPath([]); + return; + } + + setLoading(true); + try { + const path = await fetchHierarchyPath(task.parent_task_id); + setHierarchyPath(path); + } catch (error) { + console.error('Error loading task hierarchy:', error); + setHierarchyPath([]); + } finally { + setLoading(false); + } + }; + + loadHierarchy(); + }, [task?.parent_task_id, projectId, isSubTask]); + + const handleNavigateToTask = (taskId: string) => { + if (projectId) { + if (onBackClick) { + onBackClick(); + } + + // Navigate to the selected task + dispatch(setSelectedTaskId(taskId)); + dispatch(fetchTask({ taskId, projectId })); + } + }; + + if (!isSubTask || hierarchyPath.length === 0) { + return null; + } + + // Create breadcrumb items from the hierarchy path + const breadcrumbItems = [ + // Add all parent tasks in the hierarchy + ...hierarchyPath.map((hierarchyTask, index) => { + const truncatedName = truncateText(hierarchyTask.name, 25); + const shouldShowTooltip = hierarchyTask.name.length > 25; + + return { + title: ( + + + + ), + }; + }), + // Add the current task as the last item (non-clickable) + { + title: (() => { + const currentTaskName = task?.name || t('taskHeader.currentTask', 'Current Task'); + const truncatedCurrentName = truncateText(currentTaskName, 25); + const shouldShowCurrentTooltip = currentTaskName.length > 25; + + return ( + + + {truncatedCurrentName} + + + ); + })(), + }, + ]; + + return ( +
+ {loading ? ( + + {t('taskHeader.loadingHierarchy', 'Loading hierarchy...')} + + ) : ( + + )} +
+ ); +}; + +export default TaskHierarchyBreadcrumb; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index 624ff623..1cc6c680 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -9,6 +9,7 @@ import { KeyboardSensor, TouchSensor, closestCenter, + useDroppable, } from '@dnd-kit/core'; import { SortableContext, @@ -67,6 +68,101 @@ import { AddCustomColumnButton, CustomColumnHeader } from './components/CustomCo import TaskListSkeleton from './components/TaskListSkeleton'; import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer'; +// Empty Group Drop Zone Component +const EmptyGroupDropZone: React.FC<{ + groupId: string; + visibleColumns: any[]; + t: (key: string) => string; +}> = ({ groupId, visibleColumns, t }) => { + const { setNodeRef, isOver, active } = useDroppable({ + id: `empty-group-${groupId}`, + data: { + type: 'group', + groupId: groupId, + isEmpty: true, + }, + }); + + return ( +
+
+ {visibleColumns.map((column, index) => { + const emptyColumnStyle = { + width: column.width, + flexShrink: 0, + }; + return ( +
+ ); + })} +
+
+
+ {isOver && active ? t('dropTaskHere') || 'Drop task here' : t('noTasksInGroup')} +
+
+ {isOver && active && ( +
+ )} +
+ ); +}; + +// Placeholder Drop Indicator Component +const PlaceholderDropIndicator: React.FC<{ + isVisible: boolean; + visibleColumns: any[]; +}> = ({ isVisible, visibleColumns }) => { + if (!isVisible) return null; + + return ( +
+ {visibleColumns.map((column, index) => { + const columnStyle = { + width: column.width, + flexShrink: 0, + }; + return ( +
+ {/* Show "Drop task here" message in the title column */} + {column.id === 'title' && ( +
+ Drop task here +
+ )} + {/* Show subtle placeholder content in other columns */} + {column.id !== 'title' && column.id !== 'dragHandle' && ( +
+ )} +
+ ); + })} +
+ ); +}; + // Hooks and utilities import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; import { useSocket } from '@/socket/socketContext'; @@ -127,7 +223,7 @@ const TaskListV2Section: React.FC = () => { ); // Custom hooks - const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop( + const { activeId, overId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop( allTasks, groups ); @@ -465,31 +561,11 @@ const TaskListV2Section: React.FC = () => { projectId={urlProjectId || ''} /> {isGroupEmpty && !isGroupCollapsed && ( -
-
- {visibleColumns.map((column, index) => { - const emptyColumnStyle = { - width: column.width, - flexShrink: 0, - ...(column.id === 'labels' && column.width === 'auto' - ? { minWidth: '200px', flexGrow: 1 } - : {}), - }; - return ( -
- ); - })} -
-
-
- {t('noTasksInGroup')} -
-
-
+ )}
); @@ -546,12 +622,6 @@ const TaskListV2Section: React.FC = () => { const columnStyle: ColumnStyle = { width: column.width, flexShrink: 0, - ...(column.id === 'labels' && column.width === 'auto' - ? { - minWidth: '200px', - flexGrow: 1, - } - : {}), ...((column as any).minWidth && { minWidth: (column as any).minWidth }), ...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }), }; @@ -687,8 +757,9 @@ const TaskListV2Section: React.FC = () => { {renderGroup(groupIndex)} {/* Group Tasks */} - {!collapsedGroups.has(group.id) && - group.tasks.map((task, taskIndex) => { + {!collapsedGroups.has(group.id) && ( + group.tasks.length > 0 ? ( + group.tasks.map((task, taskIndex) => { const globalTaskIndex = virtuosoGroups.slice(0, groupIndex).reduce((sum, g) => sum + g.count, 0) + taskIndex; @@ -696,12 +767,41 @@ const TaskListV2Section: React.FC = () => { // Check if this is the first actual task in the group (not AddTaskRow) const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task); + // Check if we should show drop indicators + const isTaskBeingDraggedOver = overId === task.id; + const isGroupBeingDraggedOver = overId === group.id; + const isFirstTaskInGroupBeingDraggedOver = isFirstTaskInGroup && isTaskBeingDraggedOver; + return (
+ {/* Placeholder drop indicator before first task in group */} + {isFirstTaskInGroupBeingDraggedOver && ( + + )} + + {/* Placeholder drop indicator between tasks */} + {isTaskBeingDraggedOver && !isFirstTaskInGroup && ( + + )} + {renderTask(globalTaskIndex, isFirstTaskInGroup)} + + {/* Placeholder drop indicator at end of group when dragging over group */} + {isGroupBeingDraggedOver && taskIndex === group.tasks.length - 1 && ( + + )}
); - })} + }) + ) : ( + // Handle empty groups with placeholder drop indicator + overId === group.id && ( +
+ +
+ ) + ) + )}
))}
@@ -710,12 +810,12 @@ const TaskListV2Section: React.FC = () => {
{/* Drag Overlay */} - + {activeId ? ( -
+
- +
{allTasks.find(task => task.id === activeId)?.name || diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index 24571b8b..d03082ef 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -24,6 +24,7 @@ interface TaskRowProps { isSubtask?: boolean; isFirstInGroup?: boolean; updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void; + depth?: number; } const TaskRow: React.FC = memo(({ @@ -32,7 +33,8 @@ const TaskRow: React.FC = memo(({ visibleColumns, isSubtask = false, isFirstInGroup = false, - updateTaskCustomColumnValue + updateTaskCustomColumnValue, + depth = 0 }) => { // Get task data and selection state from Redux const task = useAppSelector(state => selectTaskById(state, taskId)); @@ -107,13 +109,14 @@ const TaskRow: React.FC = memo(({ handleTaskNameEdit, attributes, listeners, + depth, }); // Memoize style object to prevent unnecessary re-renders const style = useMemo(() => ({ transform: CSS.Transform.toString(transform), transition, - opacity: isDragging ? 0.5 : 1, + opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging }), [transform, transition, isDragging]); return ( diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx index 10226d03..f6c35cfc 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx @@ -22,6 +22,8 @@ interface TaskRowWithSubtasksProps { }>; isFirstInGroup?: boolean; updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void; + depth?: number; // Add depth prop to track nesting level + maxDepth?: number; // Add maxDepth prop to limit nesting } interface AddSubtaskRowProps { @@ -32,14 +34,15 @@ interface AddSubtaskRowProps { width: string; isSticky?: boolean; }>; - onSubtaskAdded: () => void; // Simplified - no rowId needed - rowId: string; // Unique identifier for this add subtask row - autoFocus?: boolean; // Whether this row should auto-focus on mount - isActive?: boolean; // Whether this row should show the input/button - onActivate?: () => void; // Simplified - no rowId needed + onSubtaskAdded: () => void; + rowId: string; + autoFocus?: boolean; + isActive?: boolean; + onActivate?: () => void; + depth?: number; // Add depth prop for proper indentation } -const AddSubtaskRow: React.FC = memo(({ +const AddSubtaskRow: React.FC = memo(({ parentTaskId, projectId, visibleColumns, @@ -47,25 +50,20 @@ const AddSubtaskRow: React.FC = memo(({ rowId, autoFocus = false, isActive = true, - onActivate + onActivate, + depth = 0 }) => { - const [isAdding, setIsAdding] = useState(autoFocus); + const { t } = useTranslation('task-list-table'); + const [isAdding, setIsAdding] = useState(false); const [subtaskName, setSubtaskName] = useState(''); const inputRef = useRef(null); - const { socket, connected } = useSocket(); - const { t } = useTranslation('task-list-table'); const dispatch = useAppDispatch(); - - // Get session data for reporter_id and team_id + const { socket, connected } = useSocket(); const currentSession = useAuthService().getCurrentSession(); - // Auto-focus when autoFocus prop is true useEffect(() => { if (autoFocus && inputRef.current) { - setIsAdding(true); - setTimeout(() => { - inputRef.current?.focus(); - }, 100); + inputRef.current.focus(); } }, [autoFocus]); @@ -141,11 +139,15 @@ const AddSubtaskRow: React.FC = memo(({ return (
- {/* Match subtask indentation pattern - tighter spacing */} -
+ {/* Match subtask indentation pattern - reduced spacing for level 1 */}
+ {/* Add additional indentation for deeper levels - increased spacing for level 2+ */} + {Array.from({ length: depth }).map((_, i) => ( +
+ ))} +
- {isActive ? ( + {isActive ? ( !isAdding ? ( )} - {/* Additional indentation for subtasks after the expand button space */} - {isSubtask &&
} + {/* Additional indentation for subtasks after the expand button space - reduced for level 1 */} + {isSubtask &&
}
{/* Task name with dynamic width */} @@ -202,8 +209,8 @@ export const TitleColumn: React.FC = memo(({ {/* Indicators container - flex-shrink-0 to prevent compression */}
- {/* Subtask count indicator - only show if count > 0 */} - {!isSubtask && task.sub_tasks_count != null && task.sub_tasks_count > 0 && ( + {/* Subtask count indicator - show for any task that can have sub-tasks */} + {depth < 2 && task.sub_tasks_count != null && task.sub_tasks_count > 0 && (
diff --git a/worklenz-frontend/src/components/task-list-v2/constants/columns.ts b/worklenz-frontend/src/components/task-list-v2/constants/columns.ts index 5f8add14..e7b34373 100644 --- a/worklenz-frontend/src/components/task-list-v2/constants/columns.ts +++ b/worklenz-frontend/src/components/task-list-v2/constants/columns.ts @@ -19,10 +19,10 @@ export const BASE_COLUMNS = [ { id: 'title', label: 'taskColumn', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME }, { id: 'description', label: 'descriptionColumn', width: '260px', key: COLUMN_KEYS.DESCRIPTION }, { id: 'progress', label: 'progressColumn', width: '120px', key: COLUMN_KEYS.PROGRESS }, - { id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES }, - { id: 'labels', label: 'labelsColumn', width: 'auto', key: COLUMN_KEYS.LABELS }, - { id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE }, { id: 'status', label: 'statusColumn', width: '120px', key: COLUMN_KEYS.STATUS }, + { id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES }, + { id: 'labels', label: 'labelsColumn', width: '250px', key: COLUMN_KEYS.LABELS }, + { id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE }, { id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY }, { id: 'timeTracking', label: 'timeTrackingColumn', width: '120px', key: COLUMN_KEYS.TIME_TRACKING }, { id: 'estimation', label: 'estimationColumn', width: '120px', key: COLUMN_KEYS.ESTIMATION }, diff --git a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts index 4394bd34..b70f4c69 100644 --- a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts +++ b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts @@ -1,12 +1,116 @@ import { useState, useCallback } from 'react'; import { DragEndEvent, DragOverEvent, DragStartEvent } from '@dnd-kit/core'; import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { reorderTasksInGroup, moveTaskBetweenGroups } from '@/features/task-management/task-management.slice'; -import { Task, TaskGroup } from '@/types/task-management.types'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { reorderTasksInGroup } from '@/features/task-management/task-management.slice'; +import { selectCurrentGrouping } from '@/features/task-management/grouping.slice'; +import { Task, TaskGroup, getSortOrderField } from '@/types/task-management.types'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { useParams } from 'react-router-dom'; +import { useAuthService } from '@/hooks/useAuth'; export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { const dispatch = useAppDispatch(); + const { socket, connected } = useSocket(); + const { projectId } = useParams(); + const currentGrouping = useAppSelector(selectCurrentGrouping); + const currentSession = useAuthService().getCurrentSession(); const [activeId, setActiveId] = useState(null); + const [overId, setOverId] = useState(null); + + // Helper function to emit socket event for persistence + const emitTaskSortChange = useCallback( + (taskId: string, sourceGroup: TaskGroup, targetGroup: TaskGroup, insertIndex: number) => { + if (!socket || !connected || !projectId) { + console.warn('Socket not connected or missing project ID'); + return; + } + + const task = allTasks.find(t => t.id === taskId); + if (!task) { + console.error('Task not found for socket emission:', taskId); + return; + } + + // Get team_id from current session + const teamId = currentSession?.team_id || ''; + + // Use new bulk update approach - recalculate ALL task orders to prevent duplicates + const taskUpdates = []; + + // Create a copy of all groups and perform the move operation + const updatedGroups = groups.map(group => ({ + ...group, + taskIds: [...group.taskIds] + })); + + // Find the source and target groups in our copy + const sourceGroupCopy = updatedGroups.find(g => g.id === sourceGroup.id)!; + const targetGroupCopy = updatedGroups.find(g => g.id === targetGroup.id)!; + + if (sourceGroup.id === targetGroup.id) { + // Same group - reorder within the group + const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId); + // Remove task from old position + sourceGroupCopy.taskIds.splice(sourceIndex, 1); + // Insert at new position + sourceGroupCopy.taskIds.splice(insertIndex, 0, taskId); + } else { + // Different groups - move task between groups + // Remove from source group + const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId); + sourceGroupCopy.taskIds.splice(sourceIndex, 1); + + // Add to target group + targetGroupCopy.taskIds.splice(insertIndex, 0, taskId); + } + + // Now assign sequential sort orders to ALL tasks across ALL groups + let currentSortOrder = 0; + updatedGroups.forEach(group => { + group.taskIds.forEach(id => { + const update: any = { + task_id: id, + sort_order: currentSortOrder + }; + + // Add group-specific fields for the moved task if it changed groups + if (id === taskId && sourceGroup.id !== targetGroup.id) { + if (currentGrouping === 'status') { + update.status_id = targetGroup.id; + } else if (currentGrouping === 'priority') { + update.priority_id = targetGroup.id; + } else if (currentGrouping === 'phase') { + update.phase_id = targetGroup.id; + } + } + + taskUpdates.push(update); + currentSortOrder++; + }); + }); + + const socketData = { + project_id: projectId, + group_by: currentGrouping || 'status', + task_updates: taskUpdates, + from_group: sourceGroup.id, + to_group: targetGroup.id, + task: { + id: task.id, + project_id: projectId, + status: task.status || '', + priority: task.priority || '', + }, + team_id: teamId, + }; + + console.log('Emitting TASK_SORT_ORDER_CHANGE:', socketData); + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData); + }, + [socket, connected, projectId, allTasks, groups, currentGrouping, currentSession] + ); const handleDragStart = useCallback((event: DragStartEvent) => { setActiveId(event.active.id as string); @@ -16,11 +120,17 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { (event: DragOverEvent) => { const { active, over } = event; - if (!over) return; + if (!over) { + setOverId(null); + return; + } const activeId = active.id; const overId = over.id; + // Set the overId for drop indicators + setOverId(overId as string); + // Find the active task and the item being dragged over const activeTask = allTasks.find(task => task.id === activeId); if (!activeTask) return; @@ -55,6 +165,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { (event: DragEndEvent) => { const { active, over } = event; setActiveId(null); + setOverId(null); if (!over || active.id === over.id) { return; @@ -77,11 +188,16 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { return; } - // Check if we're dropping on a task or a group + // Check if we're dropping on a task, group, or empty group const overTask = allTasks.find(task => task.id === overId); const overGroup = groups.find(group => group.id === overId); + + // Check if dropping on empty group drop zone + const isEmptyGroupDrop = typeof overId === 'string' && overId.startsWith('empty-group-'); + const emptyGroupId = isEmptyGroupDrop ? overId.replace('empty-group-', '') : null; + const emptyGroup = emptyGroupId ? groups.find(group => group.id === emptyGroupId) : null; - let targetGroup = overGroup; + let targetGroup = overGroup || emptyGroup; let insertIndex = 0; if (overTask) { @@ -94,6 +210,10 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { // Dropping on a group (at the end) targetGroup = overGroup; insertIndex = targetGroup.taskIds.length; + } else if (emptyGroup) { + // Dropping on an empty group + targetGroup = emptyGroup; + insertIndex = 0; // First position in empty group } if (!targetGroup) { @@ -124,16 +244,8 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { newPosition: insertIndex, }); - // Move task to the target group - dispatch( - moveTaskBetweenGroups({ - taskId: activeId as string, - sourceGroupId: activeGroup.id, - targetGroupId: targetGroup.id, - }) - ); - - // Reorder task within target group at drop position + // reorderTasksInGroup handles both same-group and cross-group moves + // No need for separate moveTaskBetweenGroups call dispatch( reorderTasksInGroup({ sourceTaskId: activeId as string, @@ -142,6 +254,9 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { destinationGroupId: targetGroup.id, }) ); + + // Emit socket event for persistence + emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex); } else { // Reordering within the same group console.log('Reordering task within same group:', { @@ -161,14 +276,18 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { destinationGroupId: activeGroup.id, }) ); + + // Emit socket event for persistence + emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex); } } }, - [allTasks, groups, dispatch] + [allTasks, groups, dispatch, emitTaskSortChange] ); return { activeId, + overId, handleDragStart, handleDragOver, handleDragEnd, diff --git a/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowColumns.tsx b/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowColumns.tsx index 344f4080..6359deb3 100644 --- a/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowColumns.tsx +++ b/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowColumns.tsx @@ -58,6 +58,9 @@ interface UseTaskRowColumnsProps { // Drag and drop attributes: any; listeners: any; + + // Depth for nested subtasks + depth?: number; } export const useTaskRowColumns = ({ @@ -84,6 +87,7 @@ export const useTaskRowColumns = ({ handleTaskNameEdit, attributes, listeners, + depth = 0, }: UseTaskRowColumnsProps) => { const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => { @@ -128,6 +132,7 @@ export const useTaskRowColumns = ({ onEditTaskName={setEditTaskName} onTaskNameChange={setTaskName} onTaskNameSave={handleTaskNameSave} + depth={depth} /> ); diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index ef2f34f9..15020447 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -7,7 +7,7 @@ import { EntityId, createSelector, } from '@reduxjs/toolkit'; -import { Task, TaskManagementState, TaskGroup, TaskGrouping } from '@/types/task-management.types'; +import { Task, TaskManagementState, TaskGroup, TaskGrouping, getSortOrderField } from '@/types/task-management.types'; import { ITaskListColumn } from '@/types/tasks/taskList.types'; import { RootState } from '@/app/store'; import { @@ -661,11 +661,11 @@ const taskManagementSlice = createSlice({ newTasks.splice(newTasks.indexOf(destinationTaskId), 0, removed); group.taskIds = newTasks; - // Update order for affected tasks. Assuming simple reordering affects order. - // This might need more sophisticated logic based on how `order` is used. + // Update order for affected tasks using the appropriate sort field + const sortField = getSortOrderField(state.grouping?.id); newTasks.forEach((id, index) => { if (newEntities[id]) { - newEntities[id] = { ...newEntities[id], order: index }; + newEntities[id] = { ...newEntities[id], [sortField]: index }; } }); } @@ -723,12 +723,13 @@ const taskManagementSlice = createSlice({ newEntities[sourceTaskId] = updatedTask; } - // Update order for affected tasks in both groups if necessary + // Update order for affected tasks in both groups using the appropriate sort field + const sortField = getSortOrderField(state.grouping?.id); sourceGroup.taskIds.forEach((id, index) => { - if (newEntities[id]) newEntities[id] = { ...newEntities[id], order: index }; + if (newEntities[id]) newEntities[id] = { ...newEntities[id], [sortField]: index }; }); destinationGroup.taskIds.forEach((id, index) => { - if (newEntities[id]) newEntities[id] = { ...newEntities[id], order: index }; + if (newEntities[id]) newEntities[id] = { ...newEntities[id], [sortField]: index }; }); } } @@ -958,8 +959,26 @@ const taskManagementSlice = createSlice({ .addCase(fetchTasksV3.fulfilled, (state, action) => { state.loading = false; const { allTasks, groups, grouping } = action.payload; - tasksAdapter.setAll(state as EntityState, allTasks || []); // Ensure allTasks is an array - state.ids = (allTasks || []).map(task => task.id); // Also update ids + + // Preserve existing timer state from old tasks before replacing + const oldTasks = state.entities; + const tasksWithTimers = (allTasks || []).map(task => { + const oldTask = oldTasks[task.id]; + if (oldTask?.timeTracking?.activeTimer) { + // Preserve the timer state from the old task + return { + ...task, + timeTracking: { + ...task.timeTracking, + activeTimer: oldTask.timeTracking.activeTimer + } + }; + } + return task; + }); + + tasksAdapter.setAll(state as EntityState, tasksWithTimers); // Ensure allTasks is an array + state.ids = tasksWithTimers.map(task => task.id); // Also update ids state.groups = groups; state.grouping = grouping; }) @@ -1010,7 +1029,7 @@ const taskManagementSlice = createSlice({ order: subtask.sort_order || subtask.order || 0, parent_task_id: parentTaskId, is_sub_task: true, - sub_tasks_count: 0, + sub_tasks_count: subtask.sub_tasks_count || 0, // Use actual count from backend show_sub_tasks: false, })); diff --git a/worklenz-frontend/src/features/task-management/taskListFields.slice.ts b/worklenz-frontend/src/features/task-management/taskListFields.slice.ts index 51a9d474..7ed2cc51 100644 --- a/worklenz-frontend/src/features/task-management/taskListFields.slice.ts +++ b/worklenz-frontend/src/features/task-management/taskListFields.slice.ts @@ -14,10 +14,10 @@ const DEFAULT_FIELDS: TaskListField[] = [ { key: 'KEY', label: 'Key', visible: false, order: 1 }, { key: 'DESCRIPTION', label: 'Description', visible: false, order: 2 }, { key: 'PROGRESS', label: 'Progress', visible: true, order: 3 }, - { key: 'ASSIGNEES', label: 'Assignees', visible: true, order: 4 }, - { key: 'LABELS', label: 'Labels', visible: true, order: 5 }, - { key: 'PHASE', label: 'Phase', visible: true, order: 6 }, - { key: 'STATUS', label: 'Status', visible: true, order: 7 }, + { key: 'STATUS', label: 'Status', visible: true, order: 4 }, + { key: 'ASSIGNEES', label: 'Assignees', visible: true, order: 5 }, + { key: 'LABELS', label: 'Labels', visible: true, order: 6 }, + { key: 'PHASE', label: 'Phase', visible: true, order: 7 }, { key: 'PRIORITY', label: 'Priority', visible: true, order: 8 }, { key: 'TIME_TRACKING', label: 'Time Tracking', visible: true, order: 9 }, { key: 'ESTIMATION', label: 'Estimation', visible: false, order: 10 }, diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index 7cf88cfc..77e18263 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -33,6 +33,7 @@ import { updateTaskDescription, updateSubTasks, updateTaskProgress, + updateTaskTimeTracking, } from '@/features/tasks/tasks.slice'; import { addTask, @@ -936,6 +937,8 @@ export const useTaskSocketHandlers = () => { const { task_id, start_time } = typeof data === 'string' ? JSON.parse(data) : data; if (!task_id) return; + const timerTimestamp = start_time ? (typeof start_time === 'number' ? start_time : parseInt(start_time)) : Date.now(); + // Update the task-management slice to include timer state const currentTask = store.getState().taskManagement.entities[task_id]; if (currentTask) { @@ -943,13 +946,16 @@ export const useTaskSocketHandlers = () => { ...currentTask, timeTracking: { ...currentTask.timeTracking, - activeTimer: start_time ? (typeof start_time === 'number' ? start_time : parseInt(start_time)) : Date.now(), + activeTimer: timerTimestamp, }, updatedAt: new Date().toISOString(), updated_at: new Date().toISOString(), }; dispatch(updateTask(updatedTask)); } + + // Also update the tasks slice activeTimers to keep both slices in sync + dispatch(updateTaskTimeTracking({ taskId: task_id, timeTracking: timerTimestamp })); } catch (error) { logger.error('Error handling timer start event:', error); } @@ -975,6 +981,9 @@ export const useTaskSocketHandlers = () => { }; dispatch(updateTask(updatedTask)); } + + // Also update the tasks slice activeTimers to keep both slices in sync + dispatch(updateTaskTimeTracking({ taskId: task_id, timeTracking: null })); } catch (error) { logger.error('Error handling timer stop event:', error); } diff --git a/worklenz-frontend/src/hooks/useTaskTimer.ts b/worklenz-frontend/src/hooks/useTaskTimer.ts index 0e7f7885..806cde90 100644 --- a/worklenz-frontend/src/hooks/useTaskTimer.ts +++ b/worklenz-frontend/src/hooks/useTaskTimer.ts @@ -50,7 +50,11 @@ export const useTaskTimer = (taskId: string, initialStartTime: number | null) => // Timer management effect useEffect(() => { - if (started && localStarted && reduxStartTime) { + if (started && reduxStartTime) { + // Sync local state with Redux state + if (!localStarted) { + setLocalStarted(true); + } clearTimerInterval(); timerTick(); intervalRef.current = setInterval(timerTick, 1000); diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index 87847125..11f6dd6a 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -41,6 +41,10 @@ export interface Task { has_subscribers?: boolean; schedule_id?: string | null; order?: number; + status_sort_order?: number; // Sort order when grouped by status + priority_sort_order?: number; // Sort order when grouped by priority + phase_sort_order?: number; // Sort order when grouped by phase + member_sort_order?: number; // Sort order when grouped by members reporter?: string; // Reporter field timeTracking?: { // Time tracking information logged?: number; @@ -173,3 +177,21 @@ export interface BulkAction { value?: any; taskIds: string[]; } + +// Helper function to get the appropriate sort order field based on grouping +export function getSortOrderField(grouping: string | undefined): keyof Task { + switch (grouping) { + case 'status': + return 'status_sort_order'; + case 'priority': + return 'priority_sort_order'; + case 'phase': + return 'phase_sort_order'; + case 'members': + return 'member_sort_order'; + case 'general': + return 'order'; // explicit general sorting + default: + return 'status_sort_order'; // Default to status sorting to match backend + } +}