diff --git a/docs/api/task-breakdown-api.md b/docs/api/task-breakdown-api.md new file mode 100644 index 000000000..50d79df86 --- /dev/null +++ b/docs/api/task-breakdown-api.md @@ -0,0 +1,195 @@ +# Task Breakdown API + +## Get Task Financial Breakdown + +**Endpoint:** `GET /api/project-finance/task/:id/breakdown` + +**Description:** Retrieves detailed financial breakdown for a single task, including members grouped by job roles with labor hours and costs. + +### Parameters + +- `id` (path parameter): UUID of the task + +### Response + +```json +{ + "success": true, + "body": { + "task": { + "id": "uuid", + "name": "Task Name", + "project_id": "uuid", + "billable": true, + "estimated_hours": 10.5, + "logged_hours": 8.25, + "estimated_labor_cost": 525.0, + "actual_labor_cost": 412.5, + "fixed_cost": 100.0, + "total_estimated_cost": 625.0, + "total_actual_cost": 512.5 + }, + "grouped_members": [ + { + "jobRole": "Frontend Developer", + "estimated_hours": 5.25, + "logged_hours": 4.0, + "estimated_cost": 262.5, + "actual_cost": 200.0, + "members": [ + { + "team_member_id": "uuid", + "name": "John Doe", + "avatar_url": "https://...", + "hourly_rate": 50.0, + "estimated_hours": 5.25, + "logged_hours": 4.0, + "estimated_cost": 262.5, + "actual_cost": 200.0 + } + ] + }, + { + "jobRole": "Backend Developer", + "estimated_hours": 5.25, + "logged_hours": 4.25, + "estimated_cost": 262.5, + "actual_cost": 212.5, + "members": [ + { + "team_member_id": "uuid", + "name": "Jane Smith", + "avatar_url": "https://...", + "hourly_rate": 50.0, + "estimated_hours": 5.25, + "logged_hours": 4.25, + "estimated_cost": 262.5, + "actual_cost": 212.5 + } + ] + } + ], + "members": [ + { + "team_member_id": "uuid", + "name": "John Doe", + "avatar_url": "https://...", + "hourly_rate": 50.0, + "job_title_name": "Frontend Developer", + "estimated_hours": 5.25, + "logged_hours": 4.0, + "estimated_cost": 262.5, + "actual_cost": 200.0 + }, + { + "team_member_id": "uuid", + "name": "Jane Smith", + "avatar_url": "https://...", + "hourly_rate": 50.0, + "job_title_name": "Backend Developer", + "estimated_hours": 5.25, + "logged_hours": 4.25, + "estimated_cost": 262.5, + "actual_cost": 212.5 + } + ] + } +} +``` + +### Error Responses + +- `404 Not Found`: Task not found +- `400 Bad Request`: Invalid task ID + +### Usage + +This endpoint is designed to work with the finance drawer component (`@finance-drawer.tsx`) to provide detailed cost breakdown information for individual tasks. The response includes: + +1. **Task Summary**: Overall task financial information +2. **Grouped Members**: Members organized by job role with aggregated costs +3. **Individual Members**: Detailed breakdown for each team member + +The data structure matches what the finance drawer expects, with members grouped by job roles and individual labor hours and costs calculated based on: +- Estimated hours divided equally among assignees +- Actual logged time per member +- Hourly rates from project rate cards +- Fixed costs added to the totals + +### Frontend Usage Example + +```typescript +import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; + +// Fetch task breakdown +const fetchTaskBreakdown = async (taskId: string) => { + try { + const response = await projectFinanceApiService.getTaskBreakdown(taskId); + const breakdown = response.body; + + console.log('Task:', breakdown.task); + console.log('Grouped Members:', breakdown.grouped_members); + console.log('Individual Members:', breakdown.members); + + return breakdown; + } catch (error) { + console.error('Error fetching task breakdown:', error); + throw error; + } +}; + +// Usage in React component +const TaskBreakdownComponent = ({ taskId }: { taskId: string }) => { + const [breakdown, setBreakdown] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const loadBreakdown = async () => { + setLoading(true); + try { + const data = await fetchTaskBreakdown(taskId); + setBreakdown(data); + } catch (error) { + // Handle error + } finally { + setLoading(false); + } + }; + + if (taskId) { + loadBreakdown(); + } + }, [taskId]); + + if (loading) return ; + if (!breakdown) return null; + + return ( +
+

{breakdown.task.name}

+

Total Estimated Cost: ${breakdown.task.total_estimated_cost}

+

Total Actual Cost: ${breakdown.task.total_actual_cost}

+ + {breakdown.grouped_members.map(group => ( +
+

{group.jobRole}

+

Hours: {group.estimated_hours} | Cost: ${group.estimated_cost}

+ {group.members.map(member => ( +
+ {member.name}: {member.estimated_hours}h @ ${member.hourly_rate}/h +
+ ))} +
+ ))} +
+ ); +}; +``` + +### Integration + +This API complements the existing finance endpoints: +- `GET /api/project-finance/project/:project_id/tasks` - Get all tasks for a project +- `PUT /api/project-finance/task/:task_id/fixed-cost` - Update task fixed cost + +The finance drawer component has been updated to automatically use this API when a task is selected, providing real-time financial breakdown data. \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250115000000-add-recursive-task-estimation.sql b/worklenz-backend/database/migrations/20250115000000-add-recursive-task-estimation.sql new file mode 100644 index 000000000..1243cdf26 --- /dev/null +++ b/worklenz-backend/database/migrations/20250115000000-add-recursive-task-estimation.sql @@ -0,0 +1,228 @@ +-- Migration: Add recursive task estimation functionality +-- This migration adds a function to calculate recursive task estimation including all subtasks +-- and modifies the get_task_form_view_model function to include this data + +BEGIN; + +-- Function to calculate recursive task estimation (including all subtasks) +CREATE OR REPLACE FUNCTION get_task_recursive_estimation(_task_id UUID) RETURNS JSON + LANGUAGE plpgsql +AS +$$ +DECLARE + _result JSON; + _has_subtasks BOOLEAN; +BEGIN + -- First check if this task has any subtasks + SELECT EXISTS( + SELECT 1 FROM tasks + WHERE parent_task_id = _task_id + AND archived = false + ) INTO _has_subtasks; + + -- If task has subtasks, calculate recursive estimation excluding parent's own estimation + IF _has_subtasks THEN + WITH RECURSIVE task_tree AS ( + -- Start with direct subtasks only (exclude the parent task itself) + SELECT + id, + parent_task_id, + COALESCE(total_minutes, 0) as total_minutes, + 1 as level -- Start at level 1 (subtasks) + FROM tasks + WHERE parent_task_id = _task_id + AND archived = false + + UNION ALL + + -- Recursive case: Get all descendant tasks + SELECT + t.id, + t.parent_task_id, + COALESCE(t.total_minutes, 0) as total_minutes, + tt.level + 1 as level + FROM tasks t + INNER JOIN task_tree tt ON t.parent_task_id = tt.id + WHERE t.archived = false + ), + task_counts AS ( + SELECT + COUNT(*) as sub_tasks_count, + SUM(total_minutes) as subtasks_total_minutes -- Sum all subtask estimations + FROM task_tree + ) + SELECT JSON_BUILD_OBJECT( + 'sub_tasks_count', COALESCE(tc.sub_tasks_count, 0), + 'own_total_minutes', 0, -- Always 0 for parent tasks + 'subtasks_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), + 'recursive_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), -- Only subtasks total + 'recursive_total_hours', FLOOR(COALESCE(tc.subtasks_total_minutes, 0) / 60), + 'recursive_remaining_minutes', COALESCE(tc.subtasks_total_minutes, 0) % 60 + ) + INTO _result + FROM task_counts tc; + ELSE + -- If task has no subtasks, use its own estimation + SELECT JSON_BUILD_OBJECT( + 'sub_tasks_count', 0, + 'own_total_minutes', COALESCE(total_minutes, 0), + 'subtasks_total_minutes', 0, + 'recursive_total_minutes', COALESCE(total_minutes, 0), -- Use own estimation + 'recursive_total_hours', FLOOR(COALESCE(total_minutes, 0) / 60), + 'recursive_remaining_minutes', COALESCE(total_minutes, 0) % 60 + ) + INTO _result + FROM tasks + WHERE id = _task_id; + END IF; + + RETURN COALESCE(_result, JSON_BUILD_OBJECT( + 'sub_tasks_count', 0, + 'own_total_minutes', 0, + 'subtasks_total_minutes', 0, + 'recursive_total_minutes', 0, + 'recursive_total_hours', 0, + 'recursive_remaining_minutes', 0 + )); +END; +$$; + +-- Update the get_task_form_view_model function to include recursive estimation +CREATE OR REPLACE FUNCTION public.get_task_form_view_model(_user_id UUID, _team_id UUID, _task_id UUID, _project_id UUID) RETURNS JSON + LANGUAGE plpgsql +AS +$$ +DECLARE + _task JSON; + _priorities JSON; + _projects JSON; + _statuses JSON; + _team_members JSON; + _assignees JSON; + _phases JSON; +BEGIN + + -- Select task info + SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON) + INTO _task + FROM (WITH RECURSIVE task_hierarchy AS ( + -- Base case: Start with the given task + SELECT id, + parent_task_id, + 0 AS level + FROM tasks + WHERE id = _task_id + + UNION ALL + + -- Recursive case: Traverse up to parent tasks + SELECT t.id, + t.parent_task_id, + th.level + 1 AS level + FROM tasks t + INNER JOIN task_hierarchy th ON t.id = th.parent_task_id + WHERE th.parent_task_id IS NOT NULL) + SELECT id, + name, + description, + start_date, + end_date, + done, + total_minutes, + priority_id, + project_id, + created_at, + updated_at, + status_id, + parent_task_id, + sort_order, + (SELECT phase_id FROM task_phase WHERE task_id = tasks.id) AS phase_id, + CONCAT((SELECT key FROM projects WHERE id = tasks.project_id), '-', task_no) AS task_key, + (SELECT start_time + FROM task_timers + WHERE task_id = tasks.id + AND user_id = _user_id) AS timer_start_time, + parent_task_id IS NOT NULL AS is_sub_task, + (SELECT COUNT('*') + FROM tasks + WHERE parent_task_id = tasks.id + AND archived IS FALSE) AS sub_tasks_count, + (SELECT COUNT(*) + FROM tasks_with_status_view tt + WHERE (tt.parent_task_id = tasks.id OR tt.task_id = tasks.id) + AND tt.is_done IS TRUE) + AS completed_count, + (SELECT COUNT(*) FROM task_attachments WHERE task_id = tasks.id) AS attachments_count, + (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON) + FROM (SELECT task_labels.label_id AS id, + (SELECT name FROM team_labels WHERE id = task_labels.label_id), + (SELECT color_code FROM team_labels WHERE id = task_labels.label_id) + FROM task_labels + WHERE task_id = tasks.id + ORDER BY name) r) AS labels, + (SELECT color_code + FROM sys_task_status_categories + WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id) AS sub_tasks_count, + (SELECT name FROM users WHERE id = tasks.reporter_id) AS reporter, + (SELECT get_task_assignees(tasks.id)) AS assignees, + (SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id) AS team_member_id, + billable, + schedule_id, + progress_value, + weight, + (SELECT MAX(level) FROM task_hierarchy) AS task_level, + (SELECT get_task_recursive_estimation(tasks.id)) AS recursive_estimation + FROM tasks + WHERE id = _task_id) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _priorities + FROM (SELECT id, name FROM task_priorities ORDER BY value) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _phases + FROM (SELECT id, name FROM project_phases WHERE project_id = _project_id ORDER BY name) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _projects + FROM (SELECT id, name + FROM projects + WHERE team_id = _team_id + AND (CASE + WHEN (is_owner(_user_id, _team_id) OR is_admin(_user_id, _team_id) IS TRUE) THEN TRUE + ELSE is_member_of_project(projects.id, _user_id, _team_id) END) + ORDER BY name) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _statuses + FROM (SELECT id, name FROM task_statuses WHERE project_id = _project_id) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _team_members + FROM (SELECT team_members.id, + (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id), + (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id), + (SELECT avatar_url + FROM team_member_info_view + WHERE team_member_info_view.team_member_id = team_members.id) + FROM team_members + LEFT JOIN users u ON team_members.user_id = u.id + WHERE team_id = _team_id + AND team_members.active IS TRUE) rec; + + SELECT get_task_assignees(_task_id) INTO _assignees; + + RETURN JSON_BUILD_OBJECT( + 'task', _task, + 'priorities', _priorities, + 'projects', _projects, + 'statuses', _statuses, + 'team_members', _team_members, + 'assignees', _assignees, + 'phases', _phases + ); +END; +$$; + +COMMIT; \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250117000000-add-project-currency.sql b/worklenz-backend/database/migrations/20250117000000-add-project-currency.sql new file mode 100644 index 000000000..f9910f76d --- /dev/null +++ b/worklenz-backend/database/migrations/20250117000000-add-project-currency.sql @@ -0,0 +1,20 @@ +-- Migration: Add currency column to projects table +-- Date: 2025-01-17 +-- Description: Adds project-specific currency support to allow different projects to use different currencies + +-- Add currency column to projects table +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'USD'; + +-- Add comment for documentation +COMMENT ON COLUMN projects.currency IS 'Project-specific currency code (e.g., USD, EUR, GBP, JPY, etc.)'; + +-- Add constraint to ensure currency codes are uppercase and 3 characters +ALTER TABLE projects + ADD CONSTRAINT projects_currency_format_check + CHECK (currency ~ '^[A-Z]{3}$'); + +-- Update existing projects to have a default currency if they don't have one +UPDATE projects +SET currency = 'USD' +WHERE currency IS NULL; \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql index b4650dc76..4bd9d7bea 100644 --- a/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql +++ b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql @@ -603,7 +603,8 @@ BEGIN schedule_id, progress_value, weight, - (SELECT MAX(level) FROM task_hierarchy) AS task_level + (SELECT MAX(level) FROM task_hierarchy) AS task_level, + (SELECT get_task_recursive_estimation(tasks.id)) AS recursive_estimation FROM tasks WHERE id = _task_id) rec; @@ -662,6 +663,89 @@ ADD COLUMN IF NOT EXISTS use_manual_progress BOOLEAN DEFAULT FALSE, ADD COLUMN IF NOT EXISTS use_weighted_progress BOOLEAN DEFAULT FALSE, ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE; +-- Function to calculate recursive task estimation (including all subtasks) +CREATE OR REPLACE FUNCTION get_task_recursive_estimation(_task_id UUID) RETURNS JSON + LANGUAGE plpgsql +AS +$$ +DECLARE + _result JSON; + _has_subtasks BOOLEAN; +BEGIN + -- First check if this task has any subtasks + SELECT EXISTS( + SELECT 1 FROM tasks + WHERE parent_task_id = _task_id + AND archived = false + ) INTO _has_subtasks; + + -- If task has subtasks, calculate recursive estimation excluding parent's own estimation + IF _has_subtasks THEN + WITH RECURSIVE task_tree AS ( + -- Start with direct subtasks only (exclude the parent task itself) + SELECT + id, + parent_task_id, + COALESCE(total_minutes, 0) as total_minutes, + 1 as level -- Start at level 1 (subtasks) + FROM tasks + WHERE parent_task_id = _task_id + AND archived = false + + UNION ALL + + -- Recursive case: Get all descendant tasks + SELECT + t.id, + t.parent_task_id, + COALESCE(t.total_minutes, 0) as total_minutes, + tt.level + 1 as level + FROM tasks t + INNER JOIN task_tree tt ON t.parent_task_id = tt.id + WHERE t.archived = false + ), + task_counts AS ( + SELECT + COUNT(*) as sub_tasks_count, + SUM(total_minutes) as subtasks_total_minutes -- Sum all subtask estimations + FROM task_tree + ) + SELECT JSON_BUILD_OBJECT( + 'sub_tasks_count', COALESCE(tc.sub_tasks_count, 0), + 'own_total_minutes', 0, -- Always 0 for parent tasks + 'subtasks_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), + 'recursive_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), -- Only subtasks total + 'recursive_total_hours', FLOOR(COALESCE(tc.subtasks_total_minutes, 0) / 60), + 'recursive_remaining_minutes', COALESCE(tc.subtasks_total_minutes, 0) % 60 + ) + INTO _result + FROM task_counts tc; + ELSE + -- If task has no subtasks, use its own estimation + SELECT JSON_BUILD_OBJECT( + 'sub_tasks_count', 0, + 'own_total_minutes', COALESCE(total_minutes, 0), + 'subtasks_total_minutes', 0, + 'recursive_total_minutes', COALESCE(total_minutes, 0), -- Use own estimation + 'recursive_total_hours', FLOOR(COALESCE(total_minutes, 0) / 60), + 'recursive_remaining_minutes', COALESCE(total_minutes, 0) % 60 + ) + INTO _result + FROM tasks + WHERE id = _task_id; + END IF; + + RETURN COALESCE(_result, JSON_BUILD_OBJECT( + 'sub_tasks_count', 0, + 'own_total_minutes', 0, + 'subtasks_total_minutes', 0, + 'recursive_total_minutes', 0, + 'recursive_total_hours', 0, + 'recursive_remaining_minutes', 0 + )); +END; +$$; + -- Add a trigger to reset manual progress when a task gets a new subtask CREATE OR REPLACE FUNCTION reset_parent_task_manual_progress() RETURNS TRIGGER AS $$ @@ -677,6 +761,22 @@ BEGIN END; $$ LANGUAGE plpgsql; +-- Add a trigger to reset parent task estimation when it gets subtasks +CREATE OR REPLACE FUNCTION reset_parent_task_estimation() RETURNS TRIGGER AS +$$ +BEGIN + -- When a task gets a new subtask (parent_task_id is set), reset the parent's total_minutes to 0 + -- This ensures parent tasks don't have their own estimation when they have subtasks + IF NEW.parent_task_id IS NOT NULL THEN + UPDATE tasks + SET total_minutes = 0 + WHERE id = NEW.parent_task_id + AND total_minutes > 0; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + -- Create the trigger on the tasks table DROP TRIGGER IF EXISTS reset_parent_manual_progress_trigger ON tasks; CREATE TRIGGER reset_parent_manual_progress_trigger @@ -684,4 +784,35 @@ AFTER INSERT OR UPDATE OF parent_task_id ON tasks FOR EACH ROW EXECUTE FUNCTION reset_parent_task_manual_progress(); +-- Create the trigger to reset parent task estimation +DROP TRIGGER IF EXISTS reset_parent_estimation_trigger ON tasks; +CREATE TRIGGER reset_parent_estimation_trigger +AFTER INSERT OR UPDATE OF parent_task_id ON tasks +FOR EACH ROW +EXECUTE FUNCTION reset_parent_task_estimation(); + +-- Function to reset all existing parent tasks' estimations to 0 +CREATE OR REPLACE FUNCTION reset_all_parent_task_estimations() RETURNS INTEGER AS +$$ +DECLARE + _updated_count INTEGER; +BEGIN + -- Update all tasks that have subtasks to have 0 estimation + UPDATE tasks + SET total_minutes = 0 + WHERE id IN ( + SELECT DISTINCT parent_task_id + FROM tasks + WHERE parent_task_id IS NOT NULL + AND archived = false + ) + AND total_minutes > 0 + AND archived = false; + + GET DIAGNOSTICS _updated_count = ROW_COUNT; + + RETURN _updated_count; +END; +$$ LANGUAGE plpgsql; + COMMIT; \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250519000000-project-finance-module-tables.sql b/worklenz-backend/database/migrations/20250519000000-project-finance-module-tables.sql new file mode 100644 index 000000000..de15213f7 --- /dev/null +++ b/worklenz-backend/database/migrations/20250519000000-project-finance-module-tables.sql @@ -0,0 +1,48 @@ +-- Dropping existing finance_rate_cards table +DROP TABLE IF EXISTS finance_rate_cards; +-- Creating table to store rate card details +CREATE TABLE finance_rate_cards +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE, + name VARCHAR NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Dropping existing finance_project_rate_card_roles table +DROP TABLE IF EXISTS finance_project_rate_card_roles CASCADE; +-- Creating table with single id primary key +CREATE TABLE finance_project_rate_card_roles +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + job_title_id UUID NOT NULL REFERENCES job_titles (id) ON DELETE CASCADE, + rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_project_role UNIQUE (project_id, job_title_id) +); + +-- Dropping existing finance_rate_card_roles table +DROP TABLE IF EXISTS finance_rate_card_roles; +-- Creating table to store role-specific rates for rate cards +CREATE TABLE finance_rate_card_roles +( + rate_card_id UUID NOT NULL REFERENCES finance_rate_cards (id) ON DELETE CASCADE, + job_title_id UUID REFERENCES job_titles(id) ON DELETE SET NULL, + rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Adding project_rate_card_role_id column to project_members +ALTER TABLE project_members + ADD COLUMN project_rate_card_role_id UUID REFERENCES finance_project_rate_card_roles(id) ON DELETE SET NULL; + +-- Adding rate_card column to projects +ALTER TABLE projects + ADD COLUMN rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL; + +ALTER TABLE finance_rate_cards + ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD'; diff --git a/worklenz-backend/database/migrations/20250520000000-add-fixed-cost-to-tasks.sql b/worklenz-backend/database/migrations/20250520000000-add-fixed-cost-to-tasks.sql new file mode 100644 index 000000000..e43aaed4b --- /dev/null +++ b/worklenz-backend/database/migrations/20250520000000-add-fixed-cost-to-tasks.sql @@ -0,0 +1,6 @@ +-- Add fixed_cost column to tasks table for project finance functionality +ALTER TABLE tasks +ADD COLUMN fixed_cost DECIMAL(10, 2) DEFAULT 0 CHECK (fixed_cost >= 0); + +-- Add comment to explain the column +COMMENT ON COLUMN tasks.fixed_cost IS 'Fixed cost for the task in addition to hourly rate calculations'; \ No newline at end of file diff --git a/worklenz-backend/database/migrations/consolidated-progress-migrations.sql b/worklenz-backend/database/migrations/consolidated-progress-migrations.sql index ef89a9236..d04c54a87 100644 --- a/worklenz-backend/database/migrations/consolidated-progress-migrations.sql +++ b/worklenz-backend/database/migrations/consolidated-progress-migrations.sql @@ -118,7 +118,7 @@ BEGIN SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id - ), 0) as logged_minutes + ), 0) / 60.0 as logged_minutes FROM tasks t WHERE t.id = _task_id ) diff --git a/worklenz-backend/database/sql/1_tables.sql b/worklenz-backend/database/sql/1_tables.sql index af6cdc0ef..27d89b578 100644 --- a/worklenz-backend/database/sql/1_tables.sql +++ b/worklenz-backend/database/sql/1_tables.sql @@ -14,6 +14,9 @@ CREATE TYPE SCHEDULE_TYPE AS ENUM ('daily', 'weekly', 'yearly', 'monthly', 'ever CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt'); +-- Add progress mode type for tasks progress tracking +CREATE TYPE PROGRESS_MODE_TYPE AS ENUM ('manual', 'weighted', 'time', 'default'); + -- START: Users CREATE SEQUENCE IF NOT EXISTS users_user_no_seq START 1; @@ -777,9 +780,15 @@ CREATE TABLE IF NOT EXISTS projects ( estimated_man_days INTEGER DEFAULT 0, hours_per_day INTEGER DEFAULT 8, health_id UUID, - estimated_working_days INTEGER DEFAULT 0 + estimated_working_days INTEGER DEFAULT 0, + use_manual_progress BOOLEAN DEFAULT FALSE, + use_weighted_progress BOOLEAN DEFAULT FALSE, + use_time_progress BOOLEAN DEFAULT FALSE, + currency VARCHAR(3) DEFAULT 'USD' ); +COMMENT ON COLUMN projects.currency IS 'Project-specific currency code (e.g., USD, EUR, GBP, JPY, etc.)'; + ALTER TABLE projects ADD CONSTRAINT projects_pk PRIMARY KEY (id); @@ -1411,9 +1420,16 @@ CREATE TABLE IF NOT EXISTS tasks ( sort_order INTEGER DEFAULT 0 NOT NULL, roadmap_sort_order INTEGER DEFAULT 0 NOT NULL, billable BOOLEAN DEFAULT TRUE, - schedule_id UUID + schedule_id UUID, + manual_progress BOOLEAN DEFAULT FALSE, + progress_value INTEGER DEFAULT NULL, + progress_mode PROGRESS_MODE_TYPE DEFAULT 'default', + weight INTEGER DEFAULT NULL, + fixed_cost DECIMAL(10, 2) DEFAULT 0 CHECK (fixed_cost >= 0) ); +COMMENT ON COLUMN tasks.fixed_cost IS 'Fixed cost for the task in addition to hourly rate calculations'; + ALTER TABLE tasks ADD CONSTRAINT tasks_pk PRIMARY KEY (id); @@ -2279,3 +2295,37 @@ ALTER TABLE organization_working_days ALTER TABLE organization_working_days ADD CONSTRAINT org_organization_id_fk FOREIGN KEY (organization_id) REFERENCES organizations; + +-- Finance module tables +CREATE TABLE IF NOT EXISTS finance_rate_cards ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE, + name VARCHAR NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + currency TEXT NOT NULL DEFAULT 'USD' +); + +CREATE TABLE IF NOT EXISTS finance_project_rate_card_roles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + job_title_id UUID NOT NULL REFERENCES job_titles (id) ON DELETE CASCADE, + rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_project_role UNIQUE (project_id, job_title_id) +); + +CREATE TABLE IF NOT EXISTS finance_rate_card_roles ( + rate_card_id UUID NOT NULL REFERENCES finance_rate_cards (id) ON DELETE CASCADE, + job_title_id UUID REFERENCES job_titles(id) ON DELETE SET NULL, + rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE project_members + ADD COLUMN IF NOT EXISTS project_rate_card_role_id UUID REFERENCES finance_project_rate_card_roles(id) ON DELETE SET NULL; + +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL; diff --git a/worklenz-backend/database/sql/4_functions.sql b/worklenz-backend/database/sql/4_functions.sql index 9c9cc8207..f422ca478 100644 --- a/worklenz-backend/database/sql/4_functions.sql +++ b/worklenz-backend/database/sql/4_functions.sql @@ -4117,7 +4117,7 @@ BEGIN 'color_code_dark', COALESCE((_task_info ->> 'color_code_dark')::TEXT, ''), 'total_tasks', COALESCE((_task_info ->> 'total_tasks')::INT, 0), 'total_completed', COALESCE((_task_info ->> 'total_completed')::INT, 0), - 'members', COALESCE((_task_info ->> 'members')::JSON, '[]'::JSON), + 'members', COALESCE((_task_info -> 'members'), '[]'::JSON), 'completed_at', _task_completed_at, 'status_category', COALESCE(_status_category, '{}'::JSON), 'schedule_id', COALESCE(_schedule_id, 'null'::JSON) @@ -5401,7 +5401,8 @@ BEGIN updated_at = CURRENT_TIMESTAMP, estimated_working_days = (_body ->> 'working_days')::INTEGER, estimated_man_days = (_body ->> 'man_days')::INTEGER, - hours_per_day = (_body ->> 'hours_per_day')::INTEGER + hours_per_day = (_body ->> 'hours_per_day')::INTEGER, + currency = COALESCE(UPPER((_body ->> 'currency')::TEXT), currency) WHERE id = (_body ->> 'id')::UUID AND team_id = _team_id RETURNING id INTO _project_id; @@ -6372,3 +6373,44 @@ BEGIN ); END; $$; + +CREATE OR REPLACE VIEW project_finance_view AS +SELECT + t.id, + t.name, + t.total_minutes / 3600.0 as estimated_hours, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0 as total_time_logged, + COALESCE((SELECT SUM(rate * (time_spent / 3600.0)) + FROM task_work_log twl + LEFT JOIN users u ON twl.user_id = u.id + LEFT JOIN team_members tm ON u.id = tm.user_id + LEFT JOIN project_members pm ON tm.id = pm.team_member_id + LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id + WHERE twl.task_id = t.id), 0) as estimated_cost, + 0 as fixed_cost, -- Default to 0 since the column doesn't exist + COALESCE(t.total_minutes / 3600.0 * + (SELECT rate FROM finance_project_rate_card_roles + WHERE project_id = t.project_id + AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1) + LIMIT 1), 0) as total_budgeted_cost, + COALESCE((SELECT SUM(rate * (time_spent / 3600.0)) + FROM task_work_log twl + LEFT JOIN users u ON twl.user_id = u.id + LEFT JOIN team_members tm ON u.id = tm.user_id + LEFT JOIN project_members pm ON tm.id = pm.team_member_id + LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id + WHERE twl.task_id = t.id), 0) as total_actual_cost, + COALESCE((SELECT SUM(rate * (time_spent / 3600.0)) + FROM task_work_log twl + LEFT JOIN users u ON twl.user_id = u.id + LEFT JOIN team_members tm ON u.id = tm.user_id + LEFT JOIN project_members pm ON tm.id = pm.team_member_id + LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id + WHERE twl.task_id = t.id), 0) - + COALESCE(t.total_minutes / 3600.0 * + (SELECT rate FROM finance_project_rate_card_roles + WHERE project_id = t.project_id + AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1) + LIMIT 1), 0) as variance, + t.project_id +FROM tasks t; diff --git a/worklenz-backend/fix-task-hierarchy.sql b/worklenz-backend/fix-task-hierarchy.sql new file mode 100644 index 000000000..602feb378 --- /dev/null +++ b/worklenz-backend/fix-task-hierarchy.sql @@ -0,0 +1,77 @@ +-- Fix task hierarchy and reset parent estimations +-- This script ensures proper parent-child relationships and resets parent estimations + +-- First, let's see the current task hierarchy +SELECT + t.id, + t.name, + t.parent_task_id, + t.total_minutes, + (SELECT name FROM tasks WHERE id = t.parent_task_id) as parent_name, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as actual_subtask_count, + t.archived +FROM tasks t +WHERE (t.name LIKE '%sub%' OR t.name LIKE '%test task%') +ORDER BY t.name, t.created_at; + +-- Reset all parent task estimations to 0 +-- This ensures parent tasks don't have their own estimation when they have subtasks +UPDATE tasks +SET total_minutes = 0 +WHERE id IN ( + SELECT DISTINCT parent_task_id + FROM tasks + WHERE parent_task_id IS NOT NULL + AND archived = false +) +AND archived = false; + +-- Verify the results after the update +SELECT + t.id, + t.name, + t.parent_task_id, + t.total_minutes as current_estimation, + (SELECT name FROM tasks WHERE id = t.parent_task_id) as parent_name, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as subtask_count, + get_task_recursive_estimation(t.id) as recursive_estimation +FROM tasks t +WHERE (t.name LIKE '%sub%' OR t.name LIKE '%test task%') +AND t.archived = false +ORDER BY t.name; + +-- Show the hierarchy in tree format +WITH RECURSIVE task_hierarchy AS ( + -- Top level tasks (no parent) + SELECT + id, + name, + parent_task_id, + total_minutes, + 0 as level, + name as path + FROM tasks + WHERE parent_task_id IS NULL + AND (name LIKE '%sub%' OR name LIKE '%test task%') + AND archived = false + + UNION ALL + + -- Child tasks + SELECT + t.id, + t.name, + t.parent_task_id, + t.total_minutes, + th.level + 1, + th.path || ' > ' || t.name + FROM tasks t + INNER JOIN task_hierarchy th ON t.parent_task_id = th.id + WHERE t.archived = false +) +SELECT + REPEAT(' ', level) || name as indented_name, + total_minutes, + get_task_recursive_estimation(id) as recursive_estimation +FROM task_hierarchy +ORDER BY path; \ No newline at end of file diff --git a/worklenz-backend/package-lock.json b/worklenz-backend/package-lock.json index 138d01ff9..a94d430af 100644 --- a/worklenz-backend/package-lock.json +++ b/worklenz-backend/package-lock.json @@ -3528,7 +3528,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -3546,7 +3545,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3559,7 +3557,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3572,14 +3569,12 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -3597,7 +3592,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -3613,7 +3607,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -3934,23 +3927,6 @@ "node": ">=8" } }, - "node_modules/@jest/core/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@jest/core/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4485,22 +4461,6 @@ "node": ">=10" } }, - "node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -7682,7 +7642,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -8076,7 +8035,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/ecdsa-sig-formatter": { @@ -9102,23 +9060,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/flat-cache/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/flatted": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", @@ -9154,7 +9095,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -9171,7 +9111,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -9300,17 +9239,6 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/fstream/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -9943,8 +9871,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.0", @@ -10085,7 +10012,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -12749,7 +12675,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/pako": { @@ -12905,7 +12830,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -12919,7 +12843,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -12936,7 +12859,6 @@ "version": "11.1.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", - "dev": true, "license": "ISC", "engines": { "node": "20 || >=22" @@ -12946,7 +12868,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -13918,7 +13839,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", - "dev": true, "license": "ISC", "dependencies": { "glob": "^11.0.0", @@ -13938,7 +13858,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -13948,7 +13867,6 @@ "version": "11.0.2", "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz", "integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==", - "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -13972,7 +13890,6 @@ "version": "10.0.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -13988,7 +13905,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -14286,7 +14202,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -14298,7 +14213,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -14651,7 +14565,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -14678,7 +14591,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -14983,22 +14895,6 @@ "node": ">=8.17.0" } }, - "node_modules/tmp/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -15671,7 +15567,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -15761,7 +15656,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -15779,7 +15673,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -15795,7 +15688,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -15808,7 +15700,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/wrap-ansi/node_modules/ansi-styles": { diff --git a/worklenz-backend/package.json b/worklenz-backend/package.json index cffa800b1..8c689b39e 100644 --- a/worklenz-backend/package.json +++ b/worklenz-backend/package.json @@ -42,6 +42,9 @@ "reportFile": "test-reporter.xml", "indent": 4 }, + "overrides": { + "rimraf": "^6.0.1" + }, "dependencies": { "@aws-sdk/client-s3": "^3.378.0", "@aws-sdk/client-ses": "^3.378.0", diff --git a/worklenz-backend/reset-existing-parent-estimations.sql b/worklenz-backend/reset-existing-parent-estimations.sql new file mode 100644 index 000000000..1e82acc67 --- /dev/null +++ b/worklenz-backend/reset-existing-parent-estimations.sql @@ -0,0 +1,29 @@ +-- Reset all existing parent task estimations to 0 +-- This script updates all tasks that have subtasks to have 0 estimation + +UPDATE tasks +SET total_minutes = 0 +WHERE id IN ( + SELECT DISTINCT parent_task_id + FROM tasks + WHERE parent_task_id IS NOT NULL + AND archived = false +) +AND total_minutes > 0 +AND archived = false; + +-- Show the results +SELECT + t.id, + t.name, + t.total_minutes as current_estimation, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as subtask_count +FROM tasks t +WHERE id IN ( + SELECT DISTINCT parent_task_id + FROM tasks + WHERE parent_task_id IS NOT NULL + AND archived = false +) +AND archived = false +ORDER BY t.name; \ No newline at end of file diff --git a/worklenz-backend/src/controllers/auth-controller.ts b/worklenz-backend/src/controllers/auth-controller.ts index 4fea4f591..c4590cc53 100644 --- a/worklenz-backend/src/controllers/auth-controller.ts +++ b/worklenz-backend/src/controllers/auth-controller.ts @@ -31,6 +31,7 @@ export default class AuthController extends WorklenzControllerBase { // Flash messages sent from passport-local-signup.ts and passport-local-login.ts const errors = req.flash()["error"] || []; const messages = req.flash()["success"] || []; + // If there are multiple messages, we will send one at a time. const auth_error = errors.length > 0 ? errors[0] : null; const message = messages.length > 0 ? messages[0] : null; diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts new file mode 100644 index 000000000..4199078a8 --- /dev/null +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -0,0 +1,1355 @@ +import { IWorkLenzRequest } from "../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../interfaces/worklenz-response"; + +import db from "../config/db"; +import { ServerResponse } from "../models/server-response"; +import WorklenzControllerBase from "./worklenz-controller-base"; +import HandleExceptions from "../decorators/handle-exceptions"; +import { TASK_STATUS_COLOR_ALPHA } from "../shared/constants"; +import { getColor } from "../shared/utils"; +import moment from "moment"; +import Excel from "exceljs"; + +// Utility function to format time in hours, minutes, seconds format +const formatTimeToHMS = (totalSeconds: number): string => { + if (!totalSeconds || totalSeconds === 0) return "0s"; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + + return parts.join(" "); +}; + +// Utility function to parse time string back to seconds for calculations +const parseTimeToSeconds = (timeString: string): number => { + if (!timeString || timeString === "0s") return 0; + + let totalSeconds = 0; + const hourMatch = timeString.match(/(\d+)h/); + const minuteMatch = timeString.match(/(\d+)m/); + const secondMatch = timeString.match(/(\d+)s/); + + if (hourMatch) totalSeconds += parseInt(hourMatch[1]) * 3600; + if (minuteMatch) totalSeconds += parseInt(minuteMatch[1]) * 60; + if (secondMatch) totalSeconds += parseInt(secondMatch[1]); + + return totalSeconds; +}; + +export default class ProjectfinanceController extends WorklenzControllerBase { + @HandleExceptions() + public static async getTasks( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const projectId = req.params.project_id; + const groupBy = req.query.group_by || "status"; + const billableFilter = req.query.billable_filter || "billable"; + + // Get project information including currency + const projectQuery = ` + SELECT id, name, currency + FROM projects + WHERE id = $1 + `; + const projectResult = await db.query(projectQuery, [projectId]); + + if (projectResult.rows.length === 0) { + return res.status(404).send(new ServerResponse(false, null, "Project not found")); + } + + const project = projectResult.rows[0]; + + // First, get the project rate cards for this project + const rateCardQuery = ` + SELECT + fprr.id, + fprr.project_id, + fprr.job_title_id, + fprr.rate, + jt.name as job_title_name + FROM finance_project_rate_card_roles fprr + LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id + WHERE fprr.project_id = $1 + ORDER BY jt.name; + `; + + const rateCardResult = await db.query(rateCardQuery, [projectId]); + const projectRateCards = rateCardResult.rows; + + // Build billable filter condition + let billableCondition = ""; + if (billableFilter === "billable") { + billableCondition = "AND t.billable = true"; + } else if (billableFilter === "non-billable") { + billableCondition = "AND t.billable = false"; + } + + // Get tasks with their financial data - support hierarchical loading + const q = ` + WITH RECURSIVE task_tree AS ( + -- Get the requested tasks (parent tasks or subtasks of a specific parent) + SELECT + t.id, + t.name, + t.parent_task_id, + t.project_id, + t.status_id, + t.priority_id, + (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, + (SELECT get_task_assignees(t.id)) as assignees, + t.billable, + COALESCE(t.fixed_cost, 0) as fixed_cost, + COALESCE(t.total_minutes * 60, 0) as estimated_seconds, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as sub_tasks_count, + 0 as level, + t.id as root_id + FROM tasks t + WHERE t.project_id = $1 + AND t.archived = false + AND t.parent_task_id IS NULL -- Only load parent tasks initially + ${billableCondition} + + UNION ALL + + -- Get all descendant tasks for aggregation + SELECT + t.id, + t.name, + t.parent_task_id, + t.project_id, + t.status_id, + t.priority_id, + (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, + (SELECT get_task_assignees(t.id)) as assignees, + t.billable, + COALESCE(t.fixed_cost, 0) as fixed_cost, + COALESCE(t.total_minutes * 60, 0) as estimated_seconds, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, + 0 as sub_tasks_count, + tt.level + 1 as level, + tt.root_id + FROM tasks t + INNER JOIN task_tree tt ON t.parent_task_id = tt.id + WHERE t.archived = false + ), + task_costs AS ( + SELECT + tt.*, + -- Calculate estimated cost based on estimated hours and assignee rates + COALESCE(( + SELECT SUM((tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)) + FROM json_array_elements(tt.assignees) AS assignee_json + LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid + AND pm.project_id = tt.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE assignee_json->>'team_member_id' IS NOT NULL + ), 0) as estimated_cost, + -- Calculate actual cost based on time logged and assignee rates + COALESCE(( + SELECT SUM(COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0)) + FROM task_work_log twl + LEFT JOIN users u ON twl.user_id = u.id + LEFT JOIN team_members tm ON u.id = tm.user_id + LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tt.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE twl.task_id = tt.id + ), 0) as actual_cost_from_logs + FROM task_tree tt + ), + aggregated_tasks AS ( + SELECT + tc.id, + tc.name, + tc.parent_task_id, + tc.status_id, + tc.priority_id, + tc.phase_id, + tc.assignees, + tc.billable, + -- Fixed cost aggregation: include current task + all descendants + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.fixed_cost) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.fixed_cost + END as fixed_cost, + tc.sub_tasks_count, + -- For parent tasks, sum values from descendants only (exclude parent task itself) + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.estimated_seconds) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id + ) + ELSE tc.estimated_seconds + END as estimated_seconds, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.total_time_logged_seconds) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id + ) + ELSE tc.total_time_logged_seconds + END as total_time_logged_seconds, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.estimated_cost) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id + ) + ELSE tc.estimated_cost + END as estimated_cost, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.actual_cost_from_logs) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id + ) + ELSE tc.actual_cost_from_logs + END as actual_cost_from_logs + FROM task_costs tc + WHERE tc.level = 0 -- Only return the requested level + ) + SELECT + at.*, + (at.estimated_cost + at.fixed_cost) as total_budget, + (at.actual_cost_from_logs + at.fixed_cost) as total_actual, + ((at.actual_cost_from_logs + at.fixed_cost) - (at.estimated_cost + at.fixed_cost)) as variance + FROM aggregated_tasks at; + `; + + const result = await db.query(q, [projectId]); + const tasks = result.rows; + + // Add color_code to each assignee and include their rate information using project_members + for (const task of tasks) { + if (Array.isArray(task.assignees)) { + for (const assignee of task.assignees) { + assignee.color_code = getColor(assignee.name); + + // Get the rate for this assignee using project_members.project_rate_card_role_id + const memberRateQuery = ` + SELECT + pm.project_rate_card_role_id, + fprr.rate, + fprr.job_title_id, + jt.name as job_title_name + FROM project_members pm + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id + WHERE pm.team_member_id = $1 AND pm.project_id = $2 + `; + + try { + const memberRateResult = await db.query(memberRateQuery, [ + assignee.team_member_id, + projectId, + ]); + if (memberRateResult.rows.length > 0) { + const memberRate = memberRateResult.rows[0]; + assignee.project_rate_card_role_id = + memberRate.project_rate_card_role_id; + assignee.rate = memberRate.rate ? Number(memberRate.rate) : 0; + assignee.job_title_id = memberRate.job_title_id; + assignee.job_title_name = memberRate.job_title_name; + } else { + // Member doesn't have a rate card role assigned + assignee.project_rate_card_role_id = null; + assignee.rate = 0; + assignee.job_title_id = null; + assignee.job_title_name = null; + } + } catch (error) { + console.error( + "Error fetching member rate from project_members:", + error + ); + assignee.project_rate_card_role_id = null; + assignee.rate = 0; + assignee.job_title_id = null; + assignee.job_title_name = null; + } + } + } + } + + // Get groups based on groupBy parameter + let groups: Array<{ + id: string; + group_name: string; + color_code: string; + color_code_dark: string; + }> = []; + + if (groupBy === "status") { + const q = ` + SELECT + ts.id, + ts.name as group_name, + stsc.color_code::text, + stsc.color_code_dark::text + FROM task_statuses ts + INNER JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE ts.project_id = $1 + ORDER BY ts.sort_order; + `; + groups = (await db.query(q, [projectId])).rows; + } else if (groupBy === "priority") { + const q = ` + SELECT + id, + name as group_name, + color_code::text, + color_code_dark::text + FROM task_priorities + ORDER BY value; + `; + groups = (await db.query(q)).rows; + } else if (groupBy === "phases") { + const q = ` + SELECT + id, + name as group_name, + color_code::text, + color_code::text as color_code_dark + FROM project_phases + WHERE project_id = $1 + ORDER BY sort_index; + `; + groups = (await db.query(q, [projectId])).rows; + + // Add TASK_STATUS_COLOR_ALPHA to color codes + for (const group of groups) { + group.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA; + group.color_code_dark = group.color_code_dark + TASK_STATUS_COLOR_ALPHA; + } + } + + // Group tasks by the selected criteria + const groupedTasks = groups.map((group) => { + const groupTasks = tasks.filter((task) => { + if (groupBy === "status") return task.status_id === group.id; + if (groupBy === "priority") return task.priority_id === group.id; + if (groupBy === "phases") return task.phase_id === group.id; + return false; + }); + + return { + group_id: group.id, + group_name: group.group_name, + color_code: group.color_code, + color_code_dark: group.color_code_dark, + tasks: groupTasks.map((task) => ({ + id: task.id, + name: task.name, + estimated_seconds: Number(task.estimated_seconds) || 0, + estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0), + total_time_logged_seconds: + Number(task.total_time_logged_seconds) || 0, + total_time_logged: formatTimeToHMS( + Number(task.total_time_logged_seconds) || 0 + ), + estimated_cost: Number(task.estimated_cost) || 0, + actual_cost_from_logs: Number(task.actual_cost_from_logs) || 0, + fixed_cost: Number(task.fixed_cost) || 0, + total_budget: Number(task.total_budget) || 0, + total_actual: Number(task.total_actual) || 0, + variance: Number(task.variance) || 0, + members: task.assignees, + billable: task.billable, + sub_tasks_count: Number(task.sub_tasks_count) || 0, + })), + }; + }); + + // Include project rate cards and currency in the response for reference + const responseData = { + groups: groupedTasks, + project_rate_cards: projectRateCards, + project: { + id: project.id, + name: project.name, + currency: project.currency || "USD" + } + }; + + return res.status(200).send(new ServerResponse(true, responseData)); + } + + @HandleExceptions() + public static async updateTaskFixedCost( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const taskId = req.params.task_id; + const { fixed_cost } = req.body; + + if (typeof fixed_cost !== "number" || fixed_cost < 0) { + return res + .status(400) + .send(new ServerResponse(false, null, "Invalid fixed cost value")); + } + + // Check if the task has subtasks - parent tasks should not have editable fixed costs + const checkParentQuery = ` + SELECT + t.id, + t.name, + (SELECT COUNT(*) FROM tasks st WHERE st.parent_task_id = t.id AND st.archived = false) as sub_tasks_count + FROM tasks t + WHERE t.id = $1 AND t.archived = false; + `; + + const checkResult = await db.query(checkParentQuery, [taskId]); + + if (checkResult.rows.length === 0) { + return res + .status(404) + .send(new ServerResponse(false, null, "Task not found")); + } + + const task = checkResult.rows[0]; + + // Prevent updating fixed cost for parent tasks + if (task.sub_tasks_count > 0) { + return res + .status(400) + .send(new ServerResponse(false, null, "Cannot update fixed cost for parent tasks. Fixed cost is calculated from subtasks.")); + } + + // Update only the specific subtask's fixed cost + const updateQuery = ` + UPDATE tasks + SET fixed_cost = $1, updated_at = NOW() + WHERE id = $2 + RETURNING id, name, fixed_cost; + `; + + const result = await db.query(updateQuery, [fixed_cost, taskId]); + + if (result.rows.length === 0) { + return res + .status(404) + .send(new ServerResponse(false, null, "Task not found")); + } + + return res.status(200).send(new ServerResponse(true, { + updated_task: result.rows[0], + message: "Fixed cost updated successfully." + })); + } + + @HandleExceptions() + public static async getTaskBreakdown( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const taskId = req.params.id; + + // Get task basic information and financial data + const taskQuery = ` + SELECT + t.id, + t.name, + t.project_id, + COALESCE(t.total_minutes, 0) / 60.0 as estimated_hours, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0 as total_time_logged, + COALESCE(t.fixed_cost, 0) as fixed_cost, + t.billable, + (SELECT get_task_assignees(t.id)) as assignees + FROM tasks t + WHERE t.id = $1 AND t.archived = false; + `; + + const taskResult = await db.query(taskQuery, [taskId]); + + if (taskResult.rows.length === 0) { + return res + .status(404) + .send(new ServerResponse(false, null, "Task not found")); + } + + const [task] = taskResult.rows; + + // Get detailed member information with rates and job titles + const membersWithRates = []; + if (Array.isArray(task.assignees)) { + for (const assignee of task.assignees) { + const memberRateQuery = ` + SELECT + tm.id as team_member_id, + u.name, + u.avatar_url, + pm.project_rate_card_role_id, + COALESCE(fprr.rate, 0) as hourly_rate, + fprr.job_title_id, + jt.name as job_title_name + FROM team_members tm + LEFT JOIN users u ON tm.user_id = u.id + LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = $1 + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id + WHERE tm.id = $2 + `; + + try { + const memberResult = await db.query(memberRateQuery, [ + task.project_id, + assignee.team_member_id, + ]); + if (memberResult.rows.length > 0) { + const [member] = memberResult.rows; + + // Get actual time logged by this member for this task + const timeLogQuery = ` + SELECT COALESCE(SUM(time_spent), 0) / 3600.0 as logged_hours + FROM task_work_log twl + LEFT JOIN users u ON twl.user_id = u.id + LEFT JOIN team_members tm ON u.id = tm.user_id + WHERE twl.task_id = $1 AND tm.id = $2 + `; + + const timeLogResult = await db.query(timeLogQuery, [ + taskId, + member.team_member_id, + ]); + const loggedHours = Number( + timeLogResult.rows[0]?.logged_hours || 0 + ); + + membersWithRates.push({ + team_member_id: member.team_member_id, + name: member.name || "Unknown User", + avatar_url: member.avatar_url, + hourly_rate: Number(member.hourly_rate || 0), + job_title_name: member.job_title_name || "Unassigned", + estimated_hours: + task.assignees.length > 0 + ? Number(task.estimated_hours) / task.assignees.length + : 0, + logged_hours: loggedHours, + estimated_cost: + (task.assignees.length > 0 + ? Number(task.estimated_hours) / task.assignees.length + : 0) * Number(member.hourly_rate || 0), + actual_cost: loggedHours * Number(member.hourly_rate || 0), + }); + } + } catch (error) { + console.error("Error fetching member details:", error); + } + } + } + + // Group members by job title and calculate totals + const groupedMembers = membersWithRates.reduce((acc: any, member: any) => { + const jobRole = member.job_title_name || "Unassigned"; + + if (!acc[jobRole]) { + acc[jobRole] = { + jobRole, + estimated_hours: 0, + logged_hours: 0, + estimated_cost: 0, + actual_cost: 0, + members: [], + }; + } + + acc[jobRole].estimated_hours += member.estimated_hours; + acc[jobRole].logged_hours += member.logged_hours; + acc[jobRole].estimated_cost += member.estimated_cost; + acc[jobRole].actual_cost += member.actual_cost; + acc[jobRole].members.push({ + team_member_id: member.team_member_id, + name: member.name, + avatar_url: member.avatar_url, + hourly_rate: member.hourly_rate, + estimated_hours: member.estimated_hours, + logged_hours: member.logged_hours, + estimated_cost: member.estimated_cost, + actual_cost: member.actual_cost, + }); + + return acc; + }, {}); + + // Calculate task totals + const taskTotals = { + estimated_hours: Number(task.estimated_hours || 0), + logged_hours: Number(task.total_time_logged || 0), + estimated_labor_cost: membersWithRates.reduce( + (sum, member) => sum + member.estimated_cost, + 0 + ), + actual_labor_cost: membersWithRates.reduce( + (sum, member) => sum + member.actual_cost, + 0 + ), + fixed_cost: Number(task.fixed_cost || 0), + total_estimated_cost: + membersWithRates.reduce( + (sum, member) => sum + member.estimated_cost, + 0 + ) + Number(task.fixed_cost || 0), + total_actual_cost: + membersWithRates.reduce((sum, member) => sum + member.actual_cost, 0) + + Number(task.fixed_cost || 0), + }; + + const responseData = { + task: { + id: task.id, + name: task.name, + project_id: task.project_id, + billable: task.billable, + ...taskTotals, + }, + grouped_members: Object.values(groupedMembers), + members: membersWithRates, + }; + + return res.status(200).send(new ServerResponse(true, responseData)); + } + + @HandleExceptions() + public static async getSubTasks( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const projectId = req.params.project_id; + const parentTaskId = req.params.parent_task_id; + const billableFilter = req.query.billable_filter || "billable"; + + if (!parentTaskId) { + return res + .status(400) + .send(new ServerResponse(false, null, "Parent task ID is required")); + } + + // Build billable filter condition for subtasks + let billableCondition = ""; + if (billableFilter === "billable") { + billableCondition = "AND t.billable = true"; + } else if (billableFilter === "non-billable") { + billableCondition = "AND t.billable = false"; + } + + // Get subtasks with their financial data, including recursive aggregation for sub-subtasks + const q = ` + WITH RECURSIVE task_tree AS ( + -- Get the requested subtasks + SELECT + t.id, + t.name, + t.parent_task_id, + t.project_id, + t.status_id, + t.priority_id, + (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, + (SELECT get_task_assignees(t.id)) as assignees, + t.billable, + COALESCE(t.fixed_cost, 0) as fixed_cost, + COALESCE(t.total_minutes * 60, 0) as estimated_seconds, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as sub_tasks_count, + 0 as level, + t.id as root_id + FROM tasks t + WHERE t.project_id = $1 + AND t.archived = false + AND t.parent_task_id = $2 + ${billableCondition} + + UNION ALL + + -- Get all descendant tasks for aggregation + SELECT + t.id, + t.name, + t.parent_task_id, + t.project_id, + t.status_id, + t.priority_id, + (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, + (SELECT get_task_assignees(t.id)) as assignees, + t.billable, + COALESCE(t.fixed_cost, 0) as fixed_cost, + COALESCE(t.total_minutes * 60, 0) as estimated_seconds, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, + 0 as sub_tasks_count, + tt.level + 1 as level, + tt.root_id + FROM tasks t + INNER JOIN task_tree tt ON t.parent_task_id = tt.id + WHERE t.archived = false + ), + task_costs AS ( + SELECT + tt.*, + -- Calculate estimated cost based on estimated hours and assignee rates + COALESCE(( + SELECT SUM((tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)) + FROM json_array_elements(tt.assignees) AS assignee_json + LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid + AND pm.project_id = tt.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE assignee_json->>'team_member_id' IS NOT NULL + ), 0) as estimated_cost, + -- Calculate actual cost based on time logged and assignee rates + COALESCE(( + SELECT SUM(COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0)) + FROM task_work_log twl + LEFT JOIN users u ON twl.user_id = u.id + LEFT JOIN team_members tm ON u.id = tm.user_id + LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tt.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE twl.task_id = tt.id + ), 0) as actual_cost_from_logs + FROM task_tree tt + ), + aggregated_tasks AS ( + SELECT + tc.id, + tc.name, + tc.parent_task_id, + tc.status_id, + tc.priority_id, + tc.phase_id, + tc.assignees, + tc.billable, + -- Fixed cost aggregation: include current task + all descendants + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.fixed_cost) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.fixed_cost + END as fixed_cost, + tc.sub_tasks_count, + -- For subtasks that have their own sub-subtasks, sum values from descendants only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.estimated_seconds) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id + ) + ELSE tc.estimated_seconds + END as estimated_seconds, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.total_time_logged_seconds) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id + ) + ELSE tc.total_time_logged_seconds + END as total_time_logged_seconds, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.estimated_cost) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id + ) + ELSE tc.estimated_cost + END as estimated_cost, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.actual_cost_from_logs) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id + ) + ELSE tc.actual_cost_from_logs + END as actual_cost_from_logs + FROM task_costs tc + WHERE tc.level = 0 -- Only return the requested level (subtasks) + ) + SELECT + at.*, + (at.estimated_cost + at.fixed_cost) as total_budget, + (at.actual_cost_from_logs + at.fixed_cost) as total_actual, + ((at.actual_cost_from_logs + at.fixed_cost) - (at.estimated_cost + at.fixed_cost)) as variance + FROM aggregated_tasks at; + `; + + const result = await db.query(q, [projectId, parentTaskId]); + const tasks = result.rows; + + // Add color_code to each assignee and include their rate information + for (const task of tasks) { + if (Array.isArray(task.assignees)) { + for (const assignee of task.assignees) { + assignee.color_code = getColor(assignee.name); + + // Get the rate for this assignee + const memberRateQuery = ` + SELECT + pm.project_rate_card_role_id, + fprr.rate, + fprr.job_title_id, + jt.name as job_title_name + FROM project_members pm + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id + WHERE pm.team_member_id = $1 AND pm.project_id = $2 + `; + + try { + const memberRateResult = await db.query(memberRateQuery, [ + assignee.team_member_id, + projectId, + ]); + if (memberRateResult.rows.length > 0) { + const memberRate = memberRateResult.rows[0]; + assignee.project_rate_card_role_id = + memberRate.project_rate_card_role_id; + assignee.rate = memberRate.rate ? Number(memberRate.rate) : 0; + assignee.job_title_id = memberRate.job_title_id; + assignee.job_title_name = memberRate.job_title_name; + } else { + assignee.project_rate_card_role_id = null; + assignee.rate = 0; + assignee.job_title_id = null; + assignee.job_title_name = null; + } + } catch (error) { + console.error("Error fetching member rate:", error); + assignee.project_rate_card_role_id = null; + assignee.rate = 0; + assignee.job_title_id = null; + assignee.job_title_name = null; + } + } + } + } + + // Format the response to match the expected structure + const formattedTasks = tasks.map((task) => ({ + id: task.id, + name: task.name, + estimated_seconds: Number(task.estimated_seconds) || 0, + estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0), + total_time_logged_seconds: Number(task.total_time_logged_seconds) || 0, + total_time_logged: formatTimeToHMS( + Number(task.total_time_logged_seconds) || 0 + ), + estimated_cost: Number(task.estimated_cost) || 0, + actual_cost_from_logs: Number(task.actual_cost_from_logs) || 0, + fixed_cost: Number(task.fixed_cost) || 0, + total_budget: Number(task.total_budget) || 0, + total_actual: Number(task.total_actual) || 0, + variance: Number(task.variance) || 0, + members: task.assignees, + billable: task.billable, + sub_tasks_count: Number(task.sub_tasks_count) || 0, + })); + + return res.status(200).send(new ServerResponse(true, formattedTasks)); + } + + @HandleExceptions() + public static async exportFinanceData( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const projectId = req.params.project_id; + const groupBy = (req.query.groupBy as string) || "status"; + const billableFilter = req.query.billable_filter || "billable"; + + // Get project name and currency for filename and export + const projectQuery = `SELECT name, currency FROM projects WHERE id = $1`; + const projectResult = await db.query(projectQuery, [projectId]); + const project = projectResult.rows[0]; + const projectName = project?.name || "Unknown Project"; + const projectCurrency = project?.currency || "USD"; + + // First, get the project rate cards for this project + const rateCardQuery = ` + SELECT + fprr.id, + fprr.project_id, + fprr.job_title_id, + fprr.rate, + jt.name as job_title_name + FROM finance_project_rate_card_roles fprr + LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id + WHERE fprr.project_id = $1 + ORDER BY jt.name; + `; + + const rateCardResult = await db.query(rateCardQuery, [projectId]); + const projectRateCards = rateCardResult.rows; + + // Build billable filter condition for export + let billableCondition = ""; + if (billableFilter === "billable") { + billableCondition = "AND t.billable = true"; + } else if (billableFilter === "non-billable") { + billableCondition = "AND t.billable = false"; + } + + // Get tasks with their financial data - support hierarchical loading + const q = ` + WITH RECURSIVE task_tree AS ( + -- Get the requested tasks (parent tasks or subtasks of a specific parent) + SELECT + t.id, + t.name, + t.parent_task_id, + t.project_id, + t.status_id, + t.priority_id, + (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, + (SELECT get_task_assignees(t.id)) as assignees, + t.billable, + COALESCE(t.fixed_cost, 0) as fixed_cost, + COALESCE(t.total_minutes * 60, 0) as estimated_seconds, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as sub_tasks_count, + 0 as level, + t.id as root_id + FROM tasks t + WHERE t.project_id = $1 + AND t.archived = false + AND t.parent_task_id IS NULL -- Only load parent tasks initially + ${billableCondition} + + UNION ALL + + -- Get all descendant tasks for aggregation + SELECT + t.id, + t.name, + t.parent_task_id, + t.project_id, + t.status_id, + t.priority_id, + (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, + (SELECT get_task_assignees(t.id)) as assignees, + t.billable, + COALESCE(t.fixed_cost, 0) as fixed_cost, + COALESCE(t.total_minutes * 60, 0) as estimated_seconds, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, + 0 as sub_tasks_count, + tt.level + 1 as level, + tt.root_id + FROM tasks t + INNER JOIN task_tree tt ON t.parent_task_id = tt.id + WHERE t.archived = false + ), + task_costs AS ( + SELECT + tt.*, + -- Calculate estimated cost based on estimated hours and assignee rates + COALESCE(( + SELECT SUM((tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)) + FROM json_array_elements(tt.assignees) AS assignee_json + LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid + AND pm.project_id = tt.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE assignee_json->>'team_member_id' IS NOT NULL + ), 0) as estimated_cost, + -- Calculate actual cost based on time logged and assignee rates + COALESCE(( + SELECT SUM(COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0)) + FROM task_work_log twl + LEFT JOIN users u ON twl.user_id = u.id + LEFT JOIN team_members tm ON u.id = tm.user_id + LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tt.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE twl.task_id = tt.id + ), 0) as actual_cost_from_logs + FROM task_tree tt + ), + aggregated_tasks AS ( + SELECT + tc.id, + tc.name, + tc.parent_task_id, + tc.status_id, + tc.priority_id, + tc.phase_id, + tc.assignees, + tc.billable, + -- Fixed cost aggregation: include current task + all descendants + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.fixed_cost) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.fixed_cost + END as fixed_cost, + tc.sub_tasks_count, + -- For parent tasks, sum values from descendants only (exclude parent task itself) + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.estimated_seconds) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id + ) + ELSE tc.estimated_seconds + END as estimated_seconds, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.total_time_logged_seconds) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id + ) + ELSE tc.total_time_logged_seconds + END as total_time_logged_seconds, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.estimated_cost) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id + ) + ELSE tc.estimated_cost + END as estimated_cost, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.actual_cost_from_logs) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id + ) + ELSE tc.actual_cost_from_logs + END as actual_cost_from_logs + FROM task_costs tc + WHERE tc.level = 0 -- Only return the requested level + ) + SELECT + at.*, + (at.estimated_cost + at.fixed_cost) as total_budget, + (at.actual_cost_from_logs + at.fixed_cost) as total_actual, + ((at.actual_cost_from_logs + at.fixed_cost) - (at.estimated_cost + at.fixed_cost)) as variance + FROM aggregated_tasks at; + `; + + const result = await db.query(q, [projectId]); + const tasks = result.rows; + + // Add color_code to each assignee and include their rate information using project_members + for (const task of tasks) { + if (Array.isArray(task.assignees)) { + for (const assignee of task.assignees) { + assignee.color_code = getColor(assignee.name); + + // Get the rate for this assignee using project_members.project_rate_card_role_id + const memberRateQuery = ` + SELECT + pm.project_rate_card_role_id, + fprr.rate, + fprr.job_title_id, + jt.name as job_title_name + FROM project_members pm + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id + WHERE pm.team_member_id = $1 AND pm.project_id = $2 + `; + + try { + const memberRateResult = await db.query(memberRateQuery, [ + assignee.team_member_id, + projectId, + ]); + if (memberRateResult.rows.length > 0) { + const memberRate = memberRateResult.rows[0]; + assignee.project_rate_card_role_id = + memberRate.project_rate_card_role_id; + assignee.rate = memberRate.rate ? Number(memberRate.rate) : 0; + assignee.job_title_id = memberRate.job_title_id; + assignee.job_title_name = memberRate.job_title_name; + } else { + // Member doesn't have a rate card role assigned + assignee.project_rate_card_role_id = null; + assignee.rate = 0; + assignee.job_title_id = null; + assignee.job_title_name = null; + } + } catch (error) { + console.error( + "Error fetching member rate from project_members:", + error + ); + assignee.project_rate_card_role_id = null; + assignee.rate = 0; + assignee.job_title_id = null; + assignee.job_title_name = null; + } + } + } + } + + // Get groups based on groupBy parameter + let groups: Array<{ + id: string; + group_name: string; + color_code: string; + color_code_dark: string; + }> = []; + + if (groupBy === "status") { + const q = ` + SELECT + ts.id, + ts.name as group_name, + stsc.color_code::text, + stsc.color_code_dark::text + FROM task_statuses ts + INNER JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE ts.project_id = $1 + ORDER BY ts.sort_order; + `; + groups = (await db.query(q, [projectId])).rows; + } else if (groupBy === "priority") { + const q = ` + SELECT + id, + name as group_name, + color_code::text, + color_code_dark::text + FROM task_priorities + ORDER BY value; + `; + groups = (await db.query(q)).rows; + } else if (groupBy === "phases") { + const q = ` + SELECT + id, + name as group_name, + color_code::text, + color_code::text as color_code_dark + FROM project_phases + WHERE project_id = $1 + ORDER BY sort_index; + `; + groups = (await db.query(q, [projectId])).rows; + + // Add TASK_STATUS_COLOR_ALPHA to color codes + for (const group of groups) { + group.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA; + group.color_code_dark = group.color_code_dark + TASK_STATUS_COLOR_ALPHA; + } + } + + // Group tasks by the selected criteria + const groupedTasks = groups.map((group) => { + const groupTasks = tasks.filter((task) => { + if (groupBy === "status") return task.status_id === group.id; + if (groupBy === "priority") return task.priority_id === group.id; + if (groupBy === "phases") return task.phase_id === group.id; + return false; + }); + + return { + group_id: group.id, + group_name: group.group_name, + color_code: group.color_code, + color_code_dark: group.color_code_dark, + tasks: groupTasks.map((task) => ({ + id: task.id, + name: task.name, + estimated_seconds: Number(task.estimated_seconds) || 0, + estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0), + total_time_logged_seconds: + Number(task.total_time_logged_seconds) || 0, + total_time_logged: formatTimeToHMS( + Number(task.total_time_logged_seconds) || 0 + ), + estimated_cost: Number(task.estimated_cost) || 0, + actual_cost_from_logs: Number(task.actual_cost_from_logs) || 0, + fixed_cost: Number(task.fixed_cost) || 0, + total_budget: Number(task.total_budget) || 0, + total_actual: Number(task.total_actual) || 0, + variance: Number(task.variance) || 0, + members: task.assignees, + billable: task.billable, + sub_tasks_count: Number(task.sub_tasks_count) || 0, + })), + }; + }); + + // Include project rate cards in the response for reference + const responseData = { + groups: groupedTasks, + project_rate_cards: projectRateCards, + }; + + // Create Excel workbook and worksheet + const workbook = new Excel.Workbook(); + const worksheet = workbook.addWorksheet("Finance Data"); + + // Add headers to the worksheet + worksheet.columns = [ + { header: "Task Name", key: "task_name", width: 30 }, + { header: "Group", key: "group_name", width: 20 }, + { header: "Estimated Hours", key: "estimated_hours", width: 15 }, + { header: "Total Time Logged", key: "total_time_logged", width: 15 }, + { header: "Estimated Cost", key: "estimated_cost", width: 15 }, + { header: "Fixed Cost", key: "fixed_cost", width: 15 }, + { header: "Total Budget", key: "total_budget", width: 15 }, + { header: "Total Actual", key: "total_actual", width: 15 }, + { header: "Variance", key: "variance", width: 15 }, + { header: "Members", key: "members", width: 30 }, + { header: "Billable", key: "billable", width: 10 }, + { header: "Sub Tasks Count", key: "sub_tasks_count", width: 15 }, + ]; + + // Add title row + worksheet.getCell( + "A1" + ).value = `Finance Data Export - ${projectName} (${projectCurrency}) - ${moment().format( + "MMM DD, YYYY" + )}`; + worksheet.mergeCells("A1:L1"); + worksheet.getCell("A1").alignment = { horizontal: "center" }; + worksheet.getCell("A1").style.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "D9D9D9" }, + }; + worksheet.getCell("A1").font = { size: 16, bold: true }; + + // Add headers on row 3 + worksheet.getRow(3).values = [ + "Task Name", + "Group", + "Estimated Hours", + "Total Time Logged", + "Estimated Cost", + "Fixed Cost", + "Total Budget", + "Total Actual", + "Variance", + "Members", + "Billable", + "Sub Tasks Count", + ]; + worksheet.getRow(3).font = { bold: true }; + + // Add data to the worksheet + let currentRow = 4; + for (const group of responseData.groups) { + for (const task of group.tasks) { + worksheet.addRow({ + task_name: task.name, + group_name: group.group_name, + estimated_hours: task.estimated_hours, + total_time_logged: task.total_time_logged, + estimated_cost: task.estimated_cost.toFixed(2), + fixed_cost: task.fixed_cost.toFixed(2), + total_budget: task.total_budget.toFixed(2), + total_actual: task.total_actual.toFixed(2), + variance: task.variance.toFixed(2), + members: task.members.map((m: any) => m.name).join(", "), + billable: task.billable ? "Yes" : "No", + sub_tasks_count: task.sub_tasks_count, + }); + currentRow++; + } + } + + // Create a buffer to hold the Excel file + const buffer = await workbook.xlsx.writeBuffer(); + + // Create filename with project name, date and time + const sanitizedProjectName = projectName + .replace(/[^a-zA-Z0-9\s]/g, "") + .replace(/\s+/g, "_"); + const dateTime = moment().format("YYYY-MM-DD_HH-mm-ss"); + const filename = `${sanitizedProjectName}_Finance_Data_${dateTime}.xlsx`; + + // Set the response headers for the Excel file + res.setHeader("Content-Disposition", `attachment; filename=${filename}`); + res.setHeader( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ); + + // Send the Excel file as a response + res.end(buffer); + } + + @HandleExceptions() + public static async updateProjectCurrency( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const projectId = req.params.project_id; + const { currency } = req.body; + + // Validate currency format (3-character uppercase code) + if (!currency || typeof currency !== "string" || !/^[A-Z]{3}$/.test(currency)) { + return res + .status(400) + .send(new ServerResponse(false, null, "Invalid currency format. Currency must be a 3-character uppercase code (e.g., USD, EUR, GBP)")); + } + + // Check if project exists and user has access + const projectCheckQuery = ` + SELECT p.id, p.name, p.currency as current_currency + FROM projects p + WHERE p.id = $1 AND p.team_id = $2 + `; + + const projectCheckResult = await db.query(projectCheckQuery, [projectId, req.user?.team_id]); + + if (projectCheckResult.rows.length === 0) { + return res + .status(404) + .send(new ServerResponse(false, null, "Project not found or access denied")); + } + + const project = projectCheckResult.rows[0]; + + // Update project currency + const updateQuery = ` + UPDATE projects + SET currency = $1, updated_at = NOW() + WHERE id = $2 AND team_id = $3 + RETURNING id, name, currency; + `; + + const result = await db.query(updateQuery, [currency, projectId, req.user?.team_id]); + + if (result.rows.length === 0) { + return res + .status(500) + .send(new ServerResponse(false, null, "Failed to update project currency")); + } + + const updatedProject = result.rows[0]; + + // Log the currency change for audit purposes + const logQuery = ` + INSERT INTO project_logs (team_id, project_id, description) + VALUES ($1, $2, $3) + `; + + const logDescription = `Project currency changed from ${project.current_currency || "USD"} to ${currency}`; + + try { + await db.query(logQuery, [req.user?.team_id, projectId, logDescription]); + } catch (error) { + console.error("Failed to log currency change:", error); + // Don't fail the request if logging fails + } + + return res.status(200).send(new ServerResponse(true, { + id: updatedProject.id, + name: updatedProject.name, + currency: updatedProject.currency, + message: `Project currency updated to ${currency}` + })); + } +} diff --git a/worklenz-backend/src/controllers/project-ratecard-controller.ts b/worklenz-backend/src/controllers/project-ratecard-controller.ts new file mode 100644 index 000000000..79069006c --- /dev/null +++ b/worklenz-backend/src/controllers/project-ratecard-controller.ts @@ -0,0 +1,262 @@ +import db from "../config/db"; +import { IWorkLenzRequest } from "../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../interfaces/worklenz-response"; +import { ServerResponse } from "../models/server-response"; +import HandleExceptions from "../decorators/handle-exceptions"; +import WorklenzControllerBase from "./worklenz-controller-base"; + +export default class ProjectRateCardController extends WorklenzControllerBase { + + // Insert a single role for a project + @HandleExceptions() + public static async createOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { project_id, job_title_id, rate } = req.body; + if (!project_id || !job_title_id || typeof rate !== "number") { + return res.status(400).send(new ServerResponse(false, null, "Invalid input")); + } + const q = ` + INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate) + VALUES ($1, $2, $3) + ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.rate + RETURNING *, + (SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle; + `; + const result = await db.query(q, [project_id, job_title_id, rate]); + return res.status(200).send(new ServerResponse(true, result.rows[0])); + } + // Insert multiple roles for a project + @HandleExceptions() + public static async createMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { project_id, roles } = req.body; + if (!Array.isArray(roles) || !project_id) { + return res.status(400).send(new ServerResponse(false, null, "Invalid input")); + } + const values = roles.map((role: any) => [ + project_id, + role.job_title_id, + role.rate + ]); + const q = ` + INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate) + VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")} + ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.rate + RETURNING *, + (SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS Jobtitle; + `; + const flatValues = values.flat(); + const result = await db.query(q, flatValues); + return res.status(200).send(new ServerResponse(true, result.rows)); + } + + // Get all roles for a project + @HandleExceptions() + public static async getByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { project_id } = req.params; + const q = ` + SELECT + fprr.*, + jt.name as jobtitle, + ( + SELECT COALESCE(json_agg(pm.id), '[]'::json) + FROM project_members pm + WHERE pm.project_rate_card_role_id = fprr.id + ) AS members + FROM finance_project_rate_card_roles fprr + LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id + WHERE fprr.project_id = $1 + ORDER BY fprr.created_at; + `; + const result = await db.query(q, [project_id]); + return res.status(200).send(new ServerResponse(true, result.rows)); + } + + // Get a single role by id + @HandleExceptions() + public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { id } = req.params; + const q = ` + SELECT + fprr.*, + jt.name as jobtitle, + ( + SELECT COALESCE(json_agg(pm.id), '[]'::json) + FROM project_members pm + WHERE pm.project_rate_card_role_id = fprr.id + ) AS members + FROM finance_project_rate_card_roles fprr + LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id + WHERE fprr.id = $1; + `; + const result = await db.query(q, [id]); + return res.status(200).send(new ServerResponse(true, result.rows[0])); + } + + // Update a single role by id + @HandleExceptions() + public static async updateById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { id } = req.params; + const { job_title_id, rate } = req.body; + const q = ` + WITH updated AS ( + UPDATE finance_project_rate_card_roles + SET job_title_id = $1, rate = $2, updated_at = NOW() + WHERE id = $3 + RETURNING * + ), + jobtitles AS ( + SELECT u.*, jt.name AS jobtitle + FROM updated u + JOIN job_titles jt ON jt.id = u.job_title_id + ), + members AS ( + SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id + FROM project_members pm + WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles) + GROUP BY pm.project_rate_card_role_id + ) + SELECT jt.*, m.members + FROM jobtitles jt + LEFT JOIN members m ON m.project_rate_card_role_id = jt.id; + `; + const result = await db.query(q, [job_title_id, rate, id]); + return res.status(200).send(new ServerResponse(true, result.rows[0])); + } + + // update project member rate for a project with members + @HandleExceptions() + public static async updateProjectMemberByProjectIdAndMemberId( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const { project_id, id } = req.params; + const { project_rate_card_role_id } = req.body; + + if (!project_id || !id || !project_rate_card_role_id) { + return res.status(400).send(new ServerResponse(false, null, "Missing values")); + } + + try { + // Step 1: Check current role assignment + const checkQuery = ` + SELECT project_rate_card_role_id + FROM project_members + WHERE id = $1 AND project_id = $2; + `; + const { rows: checkRows } = await db.query(checkQuery, [id, project_id]); + + const currentRoleId = checkRows[0]?.project_rate_card_role_id; + + if (currentRoleId !== null && currentRoleId !== project_rate_card_role_id) { + // Step 2: Fetch members with the requested role + const membersQuery = ` + SELECT COALESCE(json_agg(id), '[]'::json) AS members + FROM project_members + WHERE project_id = $1 AND project_rate_card_role_id = $2; + `; + const { rows: memberRows } = await db.query(membersQuery, [project_id, project_rate_card_role_id]); + + return res.status(200).send( + new ServerResponse(false, memberRows[0], "Already Assigned !") + ); + } + + // Step 3: Perform the update + const updateQuery = ` + UPDATE project_members + SET project_rate_card_role_id = CASE + WHEN project_rate_card_role_id = $1 THEN NULL + ELSE $1 + END + WHERE id = $2 + AND project_id = $3 + AND EXISTS ( + SELECT 1 + FROM finance_project_rate_card_roles + WHERE id = $1 AND project_id = $3 + ) + RETURNING project_rate_card_role_id; + `; + const { rows: updateRows } = await db.query(updateQuery, [project_rate_card_role_id, id, project_id]); + + if (updateRows.length === 0) { + return res.status(200).send(new ServerResponse(true, [], "Project member not found or invalid project_rate_card_role_id")); + } + + const updatedRoleId = updateRows[0].project_rate_card_role_id || project_rate_card_role_id; + + // Step 4: Fetch updated members list + const membersQuery = ` + SELECT COALESCE(json_agg(id), '[]'::json) AS members + FROM project_members + WHERE project_id = $1 AND project_rate_card_role_id = $2; + `; + const { rows: finalMembers } = await db.query(membersQuery, [project_id, updatedRoleId]); + + return res.status(200).send(new ServerResponse(true, finalMembers[0])); + } catch (error) { + return res.status(500).send(new ServerResponse(false, null, "Internal server error")); + } + } + // Update all roles for a project (delete then insert) + @HandleExceptions() + public static async updateByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { project_id, roles } = req.body; + if (!Array.isArray(roles) || !project_id) { + return res.status(400).send(new ServerResponse(false, null, "Invalid input")); + } + if (roles.length === 0) { + // If no roles provided, do nothing and return empty array + return res.status(200).send(new ServerResponse(true, [])); + } + // Build upsert query for all roles + const values = roles.map((role: any) => [ + project_id, + role.job_title_id, + role.rate + ]); + const q = ` + WITH upserted AS ( + INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate) + VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")} + ON CONFLICT (project_id, job_title_id) + DO UPDATE SET rate = EXCLUDED.rate, updated_at = NOW() + RETURNING * + ), + jobtitles AS ( + SELECT upr.*, jt.name AS jobtitle + FROM upserted upr + JOIN job_titles jt ON jt.id = upr.job_title_id + ), + members AS ( + SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id + FROM project_members pm + WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles) + GROUP BY pm.project_rate_card_role_id + ) + SELECT jt.*, m.members + FROM jobtitles jt + LEFT JOIN members m ON m.project_rate_card_role_id = jt.id; + `; + const flatValues = values.flat(); + const result = await db.query(q, flatValues); + return res.status(200).send(new ServerResponse(true, result.rows)); + } + + // Delete a single role by id + @HandleExceptions() + public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { id } = req.params; + const q = `DELETE FROM finance_project_rate_card_roles WHERE id = $1 RETURNING *;`; + const result = await db.query(q, [id]); + return res.status(200).send(new ServerResponse(true, result.rows[0])); + } + + // Delete all roles for a project + @HandleExceptions() + public static async deleteByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { project_id } = req.params; + const q = `DELETE FROM finance_project_rate_card_roles WHERE project_id = $1 RETURNING *;`; + const result = await db.query(q, [project_id]); + return res.status(200).send(new ServerResponse(true, result.rows)); + } +} diff --git a/worklenz-backend/src/controllers/projects-controller.ts b/worklenz-backend/src/controllers/projects-controller.ts index 9a2f2d749..1f0c4efc3 100644 --- a/worklenz-backend/src/controllers/projects-controller.ts +++ b/worklenz-backend/src/controllers/projects-controller.ts @@ -395,6 +395,7 @@ export default class ProjectsController extends WorklenzControllerBase { projects.folder_id, projects.phase_label, projects.category_id, + projects.currency, (projects.estimated_man_days) AS man_days, (projects.estimated_working_days) AS working_days, (projects.hours_per_day) AS hours_per_day, diff --git a/worklenz-backend/src/controllers/ratecard-controller.ts b/worklenz-backend/src/controllers/ratecard-controller.ts new file mode 100644 index 000000000..8b1fcd75d --- /dev/null +++ b/worklenz-backend/src/controllers/ratecard-controller.ts @@ -0,0 +1,157 @@ +import { IWorkLenzRequest } from "../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../interfaces/worklenz-response"; +import db from "../config/db"; +import { ServerResponse } from "../models/server-response"; +import WorklenzControllerBase from "./worklenz-controller-base"; +import HandleExceptions from "../decorators/handle-exceptions"; + +export default class RateCardController extends WorklenzControllerBase { + @HandleExceptions() + public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const q = ` + INSERT INTO finance_rate_cards (team_id, name) + VALUES ($1, $2) + RETURNING id, name, team_id, created_at, updated_at; + `; + const result = await db.query(q, [req.user?.team_id || null, req.body.name]); + const [data] = result.rows; + return res.status(200).send(new ServerResponse(true, data)); + } + + @HandleExceptions() + public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { searchQuery, sortField, sortOrder, size, offset } = this.toPaginationOptions(req.query, "name"); + + const q = ` + SELECT ROW_TO_JSON(rec) AS rate_cards + FROM ( + SELECT COUNT(*) AS total, + ( + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) + FROM ( + SELECT id, name, team_id, currency, created_at, updated_at + FROM finance_rate_cards + WHERE team_id = $1 ${searchQuery} + ORDER BY ${sortField} ${sortOrder} + LIMIT $2 OFFSET $3 + ) t + ) AS data + FROM finance_rate_cards + WHERE team_id = $1 ${searchQuery} + ) rec; + `; + const result = await db.query(q, [req.user?.team_id || null, size, offset]); + const [data] = result.rows; + + return res.status(200).send(new ServerResponse(true, data.rate_cards || this.paginatedDatasetDefaultStruct)); + } + + @HandleExceptions() + public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + // 1. Fetch the rate card + const q = ` + SELECT id, name, team_id, currency, created_at, updated_at + FROM finance_rate_cards + WHERE id = $1 AND team_id = $2; + `; + const result = await db.query(q, [req.params.id, req.user?.team_id || null]); + const [data] = result.rows; + + if (!data) { + return res.status(404).send(new ServerResponse(false, null, "Rate card not found")); + } + + // 2. Fetch job roles with job title names + const jobRolesQ = ` + SELECT + rcr.job_title_id, + jt.name AS jobTitle, + rcr.rate, + rcr.rate_card_id + FROM finance_rate_card_roles rcr + LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id + WHERE rcr.rate_card_id = $1 + `; + const jobRolesResult = await db.query(jobRolesQ, [req.params.id]); + const jobRolesList = jobRolesResult.rows; + + // 3. Return the rate card with jobRolesList + return res.status(200).send( + new ServerResponse(true, { + ...data, + jobRolesList, + }) + ); + } + + @HandleExceptions() + public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + // 1. Update the rate card + const updateRateCardQ = ` + UPDATE finance_rate_cards + SET name = $3, currency = $4, updated_at = NOW() + WHERE id = $1 AND team_id = $2 + RETURNING id, name, team_id, currency, created_at, updated_at; + `; + const result = await db.query(updateRateCardQ, [ + req.params.id, + req.user?.team_id || null, + req.body.name, + req.body.currency, + ]); + const [rateCardData] = result.rows; + + // 2. Update job roles (delete old, insert new) + if (Array.isArray(req.body.jobRolesList)) { + // Delete existing roles for this rate card + await db.query( + `DELETE FROM finance_rate_card_roles WHERE rate_card_id = $1;`, + [req.params.id] + ); + + // Insert new roles + for (const role of req.body.jobRolesList) { + if (role.job_title_id) { + await db.query( + `INSERT INTO finance_rate_card_roles (rate_card_id, job_title_id, rate) + VALUES ($1, $2, $3);`, + [req.params.id, role.job_title_id, role.rate ?? 0] + ); + } + } + } + + // 3. Get jobRolesList with job title names + const jobRolesQ = ` + SELECT + rcr.job_title_id, + jt.name AS jobTitle, + rcr.rate + FROM finance_rate_card_roles rcr + LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id + WHERE rcr.rate_card_id = $1 + `; + const jobRolesResult = await db.query(jobRolesQ, [req.params.id]); + const jobRolesList = jobRolesResult.rows; + + // 4. Return the updated rate card with jobRolesList + return res.status(200).send( + new ServerResponse(true, { + ...rateCardData, + jobRolesList, + }) + ); + } + + @HandleExceptions() + public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const q = ` + DELETE FROM finance_rate_cards + WHERE id = $1 AND team_id = $2 + RETURNING id; + `; + const result = await db.query(q, [req.params.id, req.user?.team_id || null]); + return res.status(200).send(new ServerResponse(true, result.rows.length > 0)); + } +} + diff --git a/worklenz-backend/src/controllers/reporting-controller.ts b/worklenz-backend/src/controllers/reporting-controller.ts index 6825082a3..ff0c6a2fc 100644 --- a/worklenz-backend/src/controllers/reporting-controller.ts +++ b/worklenz-backend/src/controllers/reporting-controller.ts @@ -415,15 +415,20 @@ export default class ReportingController extends WorklenzControllerBase { @HandleExceptions() public static async getMyTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const selectedTeamId = req.user?.team_id; + if (!selectedTeamId) { + return res.status(400).send(new ServerResponse(false, "No selected team")); + } const q = `SELECT team_id AS id, name FROM team_members tm LEFT JOIN teams ON teams.id = tm.team_id WHERE tm.user_id = $1 + AND tm.team_id = $2 AND role_id IN (SELECT id FROM roles WHERE (admin_role IS TRUE OR owner IS TRUE)) ORDER BY name;`; - const result = await db.query(q, [req.user?.id]); + const result = await db.query(q, [req.user?.id, selectedTeamId]); result.rows.forEach((team: any) => team.selected = true); return res.status(200).send(new ServerResponse(true, result.rows)); } diff --git a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts index 4db8e3d54..1987d0fb1 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts @@ -15,6 +15,25 @@ enum IToggleOptions { } export default class ReportingAllocationController extends ReportingControllerBase { + // Helper method to build billable query with custom table alias + private static buildBillableQueryWithAlias(selectedStatuses: { billable: boolean; nonBillable: boolean }, tableAlias: string = 'tasks'): string { + const { billable, nonBillable } = selectedStatuses; + + if (billable && nonBillable) { + // Both are enabled, no need to filter + return ""; + } else if (billable && !nonBillable) { + // Only billable is enabled - show only billable tasks + return ` AND ${tableAlias}.billable IS TRUE`; + } else if (!billable && nonBillable) { + // Only non-billable is enabled - show only non-billable tasks + return ` AND ${tableAlias}.billable IS FALSE`; + } else { + // Neither selected - this shouldn't happen in normal UI flow + return ""; + } + } + private static async getTimeLoggedByProjects(projects: string[], users: string[], key: string, dateRange: string[], archived = false, user_id = "", billable: { billable: boolean; nonBillable: boolean }): Promise { try { const projectIds = projects.map(p => `'${p}'`).join(","); @@ -77,8 +96,8 @@ export default class ReportingAllocationController extends ReportingControllerBa sps.icon AS status_icon, (SELECT COUNT(*) FROM tasks - WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END ${billableQuery} - AND project_id = projects.id) AS all_tasks_count, + WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END + AND project_id = projects.id ${billableQuery}) AS all_tasks_count, (SELECT COUNT(*) FROM tasks WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END @@ -94,10 +113,11 @@ export default class ReportingAllocationController extends ReportingControllerBa SELECT name, (SELECT COALESCE(SUM(time_spent), 0) FROM task_work_log - LEFT JOIN tasks ON task_work_log.task_id = tasks.id - WHERE user_id = users.id ${billableQuery} + LEFT JOIN tasks ON task_work_log.task_id = tasks.id + WHERE user_id = users.id AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END AND tasks.project_id = projects.id + ${billableQuery} ${duration}) AS time_logged FROM users WHERE id IN (${userIds}) @@ -121,10 +141,11 @@ export default class ReportingAllocationController extends ReportingControllerBa const q = `(SELECT id, (SELECT COALESCE(SUM(time_spent), 0) FROM task_work_log - LEFT JOIN tasks ON task_work_log.task_id = tasks.id ${billableQuery} + LEFT JOIN tasks ON task_work_log.task_id = tasks.id WHERE user_id = users.id AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END AND tasks.project_id IN (${projectIds}) + ${billableQuery} ${duration}) AS time_logged FROM users WHERE id IN (${userIds}) @@ -346,6 +367,8 @@ export default class ReportingAllocationController extends ReportingControllerBa const projects = (req.body.projects || []) as string[]; const projectIds = projects.map(p => `'${p}'`).join(","); + const categories = (req.body.categories || []) as string[]; + const noCategory = req.body.noCategory || false; const billable = req.body.billable; if (!teamIds || !projectIds.length) @@ -361,6 +384,33 @@ export default class ReportingAllocationController extends ReportingControllerBa const billableQuery = this.buildBillableQuery(billable); + // Prepare projects filter + let projectsFilter = ""; + if (projectIds.length > 0) { + projectsFilter = `AND p.id IN (${projectIds})`; + } else { + // If no projects are selected, don't show any data + projectsFilter = `AND 1=0`; // This will match no rows + } + + // Prepare categories filter - updated logic + let categoriesFilter = ""; + if (categories.length > 0 && noCategory) { + // Both specific categories and "No Category" are selected + const categoryIds = categories.map(id => `'${id}'`).join(","); + categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`; + } else if (categories.length === 0 && noCategory) { + // Only "No Category" is selected + categoriesFilter = `AND p.category_id IS NULL`; + } else if (categories.length > 0 && !noCategory) { + // Only specific categories are selected + const categoryIds = categories.map(id => `'${id}'`).join(","); + categoriesFilter = `AND p.category_id IN (${categoryIds})`; + } else { + // categories.length === 0 && !noCategory - no categories selected, show nothing + categoriesFilter = `AND 1=0`; // This will match no rows + } + const q = ` SELECT p.id, p.name, @@ -368,13 +418,15 @@ export default class ReportingAllocationController extends ReportingControllerBa SUM(total_minutes) AS estimated, color_code FROM projects p - LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery} + LEFT JOIN tasks ON tasks.project_id = p.id LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id - WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} + WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} ${categoriesFilter} ${billableQuery} GROUP BY p.id, p.name ORDER BY logged_time DESC;`; const result = await db.query(q, []); + const utilization = (req.body.utilization || []) as string[]; + const data = []; for (const project of result.rows) { @@ -401,10 +453,12 @@ export default class ReportingAllocationController extends ReportingControllerBa const projects = (req.body.projects || []) as string[]; const projectIds = projects.map(p => `'${p}'`).join(","); + const categories = (req.body.categories || []) as string[]; + const noCategory = req.body.noCategory || false; const billable = req.body.billable; - if (!teamIds || !projectIds.length) - return res.status(200).send(new ServerResponse(true, { users: [], projects: [] })); + if (!teamIds) + return res.status(200).send(new ServerResponse(true, { filteredRows: [], totals: { total_time_logs: "0", total_estimated_hours: "0", total_utilization: "0" } })); const { duration, date_range } = req.body; @@ -416,7 +470,9 @@ export default class ReportingAllocationController extends ReportingControllerBa endDate = moment(date_range[1]); } else if (duration === DATE_RANGES.ALL_TIME) { // Fetch the earliest start_date (or created_at if null) from selected projects - const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`; + const minDateQuery = projectIds.length > 0 + ? `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})` + : `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE team_id IN (${teamIds})`; const minDateResult = await db.query(minDateQuery, []); const minDate = minDateResult.rows[0]?.min_date; startDate = minDate ? moment(minDate) : moment('2000-01-01'); @@ -445,59 +501,223 @@ export default class ReportingAllocationController extends ReportingControllerBa } } - // Count only weekdays (Mon-Fri) in the period + // Get organization working days + const orgWorkingDaysQuery = ` + SELECT monday, tuesday, wednesday, thursday, friday, saturday, sunday + FROM organization_working_days + WHERE organization_id IN ( + SELECT t.organization_id + FROM teams t + WHERE t.id IN (${teamIds}) + LIMIT 1 + ); + `; + const orgWorkingDaysResult = await db.query(orgWorkingDaysQuery, []); + const workingDaysConfig = orgWorkingDaysResult.rows[0] || { + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: false, + sunday: false + }; + + // Count working days based on organization settings let workingDays = 0; let current = startDate.clone(); while (current.isSameOrBefore(endDate, 'day')) { const day = current.isoWeekday(); - if (day >= 1 && day <= 5) workingDays++; + if ( + (day === 1 && workingDaysConfig.monday) || + (day === 2 && workingDaysConfig.tuesday) || + (day === 3 && workingDaysConfig.wednesday) || + (day === 4 && workingDaysConfig.thursday) || + (day === 5 && workingDaysConfig.friday) || + (day === 6 && workingDaysConfig.saturday) || + (day === 7 && workingDaysConfig.sunday) + ) { + workingDays++; + } current.add(1, 'day'); } - // Get hours_per_day for all selected projects - const projectHoursQuery = `SELECT id, hours_per_day FROM projects WHERE id IN (${projectIds})`; - const projectHoursResult = await db.query(projectHoursQuery, []); - const projectHoursMap: Record = {}; - for (const row of projectHoursResult.rows) { - projectHoursMap[row.id] = row.hours_per_day || 8; - } - // Sum total working hours for all selected projects - let totalWorkingHours = 0; - for (const pid of Object.keys(projectHoursMap)) { - totalWorkingHours += workingDays * projectHoursMap[pid]; + // Get organization working hours + const orgWorkingHoursQuery = `SELECT working_hours FROM organizations WHERE id = (SELECT t.organization_id FROM teams t WHERE t.id IN (${teamIds}) LIMIT 1)`; + const orgWorkingHoursResult = await db.query(orgWorkingHoursQuery, []); + const orgWorkingHours = orgWorkingHoursResult.rows[0]?.working_hours || 8; + + // Calculate total working hours with minimum baseline for non-working day scenarios + let totalWorkingHours = workingDays * orgWorkingHours; + let isNonWorkingPeriod = false; + + // If no working days but there might be logged time, set minimum baseline + // This ensures that time logged on non-working days is treated as over-utilization + // Business Logic: If someone works on weekends/holidays when workingDays = 0, + // we use a minimal baseline (1 hour) so any logged time results in >100% utilization + if (totalWorkingHours === 0) { + totalWorkingHours = 1; // Minimal baseline to ensure over-utilization + isNonWorkingPeriod = true; } - const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range); const archivedClause = archived ? "" : `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `; - const billableQuery = this.buildBillableQuery(billable); + const billableQuery = this.buildBillableQueryWithAlias(billable, 't'); + const members = (req.body.members || []) as string[]; + + // Prepare members filter - updated logic to handle Clear All scenario + let membersFilter = ""; + if (members.length > 0) { + const memberIds = members.map(id => `'${id}'`).join(","); + membersFilter = `AND tmiv.team_member_id IN (${memberIds})`; + } else { + // No members selected - show no data (Clear All scenario) + membersFilter = `AND 1=0`; // This will match no rows + } + // Prepare projects filter + let projectsFilter = ""; + if (projectIds.length > 0) { + projectsFilter = `AND p.id IN (${projectIds})`; + } else { + // If no projects are selected, don't show any data + projectsFilter = `AND 1=0`; // This will match no rows + } + + // Prepare categories filter - updated logic + let categoriesFilter = ""; + if (categories.length > 0 && noCategory) { + // Both specific categories and "No Category" are selected + const categoryIds = categories.map(id => `'${id}'`).join(","); + categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`; + } else if (categories.length === 0 && noCategory) { + // Only "No Category" is selected + categoriesFilter = `AND p.category_id IS NULL`; + } else if (categories.length > 0 && !noCategory) { + // Only specific categories are selected + const categoryIds = categories.map(id => `'${id}'`).join(","); + categoriesFilter = `AND p.category_id IN (${categoryIds})`; + } else { + // categories.length === 0 && !noCategory - no categories selected, show nothing + categoriesFilter = `AND 1=0`; // This will match no rows + } + + // Create custom duration clause for twl table alias + let customDurationClause = ""; + if (date_range && date_range.length === 2) { + const start = moment(date_range[0]).format("YYYY-MM-DD"); + const end = moment(date_range[1]).format("YYYY-MM-DD"); + if (start === end) { + customDurationClause = `AND twl.created_at::DATE = '${start}'::DATE`; + } else { + customDurationClause = `AND twl.created_at::DATE >= '${start}'::DATE AND twl.created_at < '${end}'::DATE + INTERVAL '1 day'`; + } + } else { + const key = duration || DATE_RANGES.LAST_WEEK; + if (key === DATE_RANGES.YESTERDAY) + customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND twl.created_at < CURRENT_DATE::DATE"; + else if (key === DATE_RANGES.LAST_WEEK) + customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'"; + else if (key === DATE_RANGES.LAST_MONTH) + customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'"; + else if (key === DATE_RANGES.LAST_QUARTER) + customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'"; + } + + // Modified query to start from team members and calculate filtered time logs const q = ` - SELECT tmiv.email, tmiv.name, SUM(time_spent) AS logged_time - FROM team_member_info_view tmiv - LEFT JOIN task_work_log ON task_work_log.user_id = tmiv.user_id - LEFT JOIN tasks ON tasks.id = task_work_log.task_id ${billableQuery} - LEFT JOIN projects p ON p.id = tasks.project_id AND p.team_id = tmiv.team_id - WHERE p.id IN (${projectIds}) - ${durationClause} ${archivedClause} - GROUP BY tmiv.email, tmiv.name - ORDER BY logged_time DESC;`; + SELECT + tmiv.team_member_id, + tmiv.email, + tmiv.name, + COALESCE( + (SELECT SUM(twl.time_spent) + FROM task_work_log twl + LEFT JOIN tasks t ON t.id = twl.task_id + LEFT JOIN projects p ON p.id = t.project_id + WHERE twl.user_id = tmiv.user_id + ${customDurationClause} + ${projectsFilter} + ${categoriesFilter} + ${archivedClause} + ${billableQuery} + AND p.team_id = tmiv.team_id + ), 0 + ) AS logged_time + FROM team_member_info_view tmiv + WHERE tmiv.team_id IN (${teamIds}) + AND tmiv.active = TRUE + ${membersFilter} + GROUP BY tmiv.email, tmiv.name, tmiv.team_member_id, tmiv.user_id, tmiv.team_id + ORDER BY logged_time DESC;`; + const result = await db.query(q, []); + const utilization = (req.body.utilization || []) as string[]; + + // Precompute totalWorkingHours * 3600 for efficiency + const totalWorkingSeconds = totalWorkingHours * 3600; + + // calculate utilization state + for (let i = 0, len = result.rows.length; i < len; i++) { + const member = result.rows[i]; + const loggedSeconds = member.logged_time ? parseFloat(member.logged_time) : 0; + const utilizedHours = loggedSeconds / 3600; + + // For individual members, use the same logic as total calculation + let memberWorkingHours = totalWorkingHours; + if (isNonWorkingPeriod && loggedSeconds > 0) { + // Any time logged during non-working period should be treated as over-utilization + memberWorkingHours = Math.min(utilizedHours, 1); // Use actual time or 1 hour, whichever is smaller + } + + const utilizationPercent = memberWorkingHours > 0 && loggedSeconds + ? ((loggedSeconds / (memberWorkingHours * 3600)) * 100) + : 0; + const overUnder = utilizedHours - memberWorkingHours; - for (const member of result.rows) { - member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0; + member.value = utilizedHours ? parseFloat(utilizedHours.toFixed(2)) : 0; member.color_code = getColor(member.name); - member.total_working_hours = totalWorkingHours; - member.utilization_percent = (totalWorkingHours > 0 && member.logged_time) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00'; - member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00'; - // Over/under utilized hours: utilized_hours - total_working_hours - const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0; + member.total_working_hours = memberWorkingHours; + member.utilization_percent = utilizationPercent.toFixed(2); + member.utilized_hours = utilizedHours.toFixed(2); member.over_under_utilized_hours = overUnder.toFixed(2); + + if (utilizationPercent < 90) { + member.utilization_state = 'under'; + } else if (utilizationPercent <= 110) { + member.utilization_state = 'optimal'; + } else { + member.utilization_state = 'over'; + } + } + + // Apply utilization filter + let filteredRows; + if (utilization.length > 0) { + // Filter to only show selected utilization states + filteredRows = result.rows.filter(member => utilization.includes(member.utilization_state)); + } else { + // No utilization states selected - show no data (Clear All scenario) + filteredRows = []; } - return res.status(200).send(new ServerResponse(true, result.rows)); + // Calculate totals + const total_time_logs = filteredRows.reduce((sum, member) => sum + parseFloat(member.logged_time || '0'), 0); + const total_estimated_hours = totalWorkingHours * filteredRows.length; // Total for all members + const total_utilization = total_time_logs > 0 && total_estimated_hours > 0 + ? ((total_time_logs / (total_estimated_hours * 3600)) * 100).toFixed(1) + : '0'; + + return res.status(200).send(new ServerResponse(true, { + filteredRows, + totals: { + total_time_logs: ((total_time_logs / 3600).toFixed(2)).toString(), + total_estimated_hours: total_estimated_hours.toString(), + total_utilization: total_utilization.toString(), + }, + })); } @HandleExceptions() @@ -580,6 +800,9 @@ export default class ReportingAllocationController extends ReportingControllerBa const projects = (req.body.projects || []) as string[]; const projectIds = projects.map(p => `'${p}'`).join(","); + + const categories = (req.body.categories || []) as string[]; + const noCategory = req.body.noCategory || false; const { type, billable } = req.body; if (!teamIds || !projectIds.length) @@ -595,6 +818,33 @@ export default class ReportingAllocationController extends ReportingControllerBa const billableQuery = this.buildBillableQuery(billable); + // Prepare projects filter + let projectsFilter = ""; + if (projectIds.length > 0) { + projectsFilter = `AND p.id IN (${projectIds})`; + } else { + // If no projects are selected, don't show any data + projectsFilter = `AND 1=0`; // This will match no rows + } + + // Prepare categories filter - updated logic + let categoriesFilter = ""; + if (categories.length > 0 && noCategory) { + // Both specific categories and "No Category" are selected + const categoryIds = categories.map(id => `'${id}'`).join(","); + categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`; + } else if (categories.length === 0 && noCategory) { + // Only "No Category" is selected + categoriesFilter = `AND p.category_id IS NULL`; + } else if (categories.length > 0 && !noCategory) { + // Only specific categories are selected + const categoryIds = categories.map(id => `'${id}'`).join(","); + categoriesFilter = `AND p.category_id IN (${categoryIds})`; + } else { + // categories.length === 0 && !noCategory - no categories selected, show nothing + categoriesFilter = `AND 1=0`; // This will match no rows + } + const q = ` SELECT p.id, p.name, @@ -608,9 +858,9 @@ export default class ReportingAllocationController extends ReportingControllerBa WHERE project_id = p.id) AS estimated, color_code FROM projects p - LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery} + LEFT JOIN tasks ON tasks.project_id = p.id LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id - WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} + WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} ${categoriesFilter} ${billableQuery} GROUP BY p.id, p.name ORDER BY logged_time DESC;`; const result = await db.query(q, []); diff --git a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts index 975004372..44050265d 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts @@ -31,6 +31,7 @@ export default class ReportingMembersController extends ReportingControllerBase const completedDurationClasue = this.completedDurationFilter(key, dateRange); const overdueActivityLogsClause = this.getActivityLogsOverdue(key, dateRange); const activityLogCreationFilter = this.getActivityLogsCreationClause(key, dateRange); + const timeLogDateRangeClause = this.getTimeLogDateRangeClause(key, dateRange); const q = `SELECT COUNT(DISTINCT email) AS total, (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) @@ -100,7 +101,25 @@ export default class ReportingMembersController extends ReportingControllerBase FROM tasks t LEFT JOIN tasks_assignees ta ON t.id = ta.task_id WHERE team_member_id = tmiv.team_member_id - AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs + AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs, + + (SELECT COALESCE(SUM(twl.time_spent), 0) + FROM task_work_log twl + LEFT JOIN tasks t ON twl.task_id = t.id + WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id) + AND t.billable IS TRUE + AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1) + ${timeLogDateRangeClause} + ${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS billable_time, + + (SELECT COALESCE(SUM(twl.time_spent), 0) + FROM task_work_log twl + LEFT JOIN tasks t ON twl.task_id = t.id + WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id) + AND t.billable IS FALSE + AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1) + ${timeLogDateRangeClause} + ${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS non_billable_time FROM team_member_info_view tmiv WHERE tmiv.team_id = $1 ${teamsClause} AND tmiv.team_member_id IN (SELECT team_member_id @@ -311,6 +330,30 @@ export default class ReportingMembersController extends ReportingControllerBase return ""; } + protected static getTimeLogDateRangeClause(key: string, dateRange: string[]) { + if (dateRange.length === 2) { + const start = moment(dateRange[0]).format("YYYY-MM-DD"); + const end = moment(dateRange[1]).format("YYYY-MM-DD"); + + if (start === end) { + return `AND twl.created_at::DATE = '${start}'::DATE`; + } + + return `AND twl.created_at::DATE >= '${start}'::DATE AND twl.created_at < '${end}'::DATE + INTERVAL '1 day'`; + } + + if (key === DATE_RANGES.YESTERDAY) + return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND twl.created_at < CURRENT_DATE::DATE`; + if (key === DATE_RANGES.LAST_WEEK) + return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`; + if (key === DATE_RANGES.LAST_MONTH) + return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`; + if (key === DATE_RANGES.LAST_QUARTER) + return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`; + + return ""; + } + private static formatDuration(duration: moment.Duration) { const empty = "0h 0m"; let format = ""; @@ -423,6 +466,8 @@ export default class ReportingMembersController extends ReportingControllerBase { header: "Overdue Tasks", key: "overdue_tasks", width: 20 }, { header: "Completed Tasks", key: "completed_tasks", width: 20 }, { header: "Ongoing Tasks", key: "ongoing_tasks", width: 20 }, + { header: "Billable Time (seconds)", key: "billable_time", width: 25 }, + { header: "Non-Billable Time (seconds)", key: "non_billable_time", width: 25 }, { header: "Done Tasks(%)", key: "done_tasks", width: 20 }, { header: "Doing Tasks(%)", key: "doing_tasks", width: 20 }, { header: "Todo Tasks(%)", key: "todo_tasks", width: 20 } @@ -430,14 +475,14 @@ export default class ReportingMembersController extends ReportingControllerBase // set title sheet.getCell("A1").value = `Members from ${teamName}`; - sheet.mergeCells("A1:K1"); + sheet.mergeCells("A1:M1"); sheet.getCell("A1").alignment = { horizontal: "center" }; sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } }; sheet.getCell("A1").font = { size: 16 }; // set export date sheet.getCell("A2").value = `Exported on : ${exportDate}`; - sheet.mergeCells("A2:K2"); + sheet.mergeCells("A2:M2"); sheet.getCell("A2").alignment = { horizontal: "center" }; sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } }; sheet.getCell("A2").font = { size: 12 }; @@ -447,7 +492,7 @@ export default class ReportingMembersController extends ReportingControllerBase sheet.mergeCells("A3:D3"); // set table headers - sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"]; + sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Billable Time (seconds)", "Non-Billable Time (seconds)", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"]; sheet.getRow(5).font = { bold: true }; for (const member of result.members) { @@ -458,6 +503,8 @@ export default class ReportingMembersController extends ReportingControllerBase overdue_tasks: member.overdue, completed_tasks: member.completed, ongoing_tasks: member.ongoing, + billable_time: member.billable_time || 0, + non_billable_time: member.non_billable_time || 0, done_tasks: member.completed, doing_tasks: member.ongoing_by_activity_logs, todo_tasks: member.todo_by_activity_logs diff --git a/worklenz-backend/src/controllers/schedule-v2/schedule-controller.ts b/worklenz-backend/src/controllers/schedule-v2/schedule-controller.ts index a008c06db..a81ec0d01 100644 --- a/worklenz-backend/src/controllers/schedule-v2/schedule-controller.ts +++ b/worklenz-backend/src/controllers/schedule-v2/schedule-controller.ts @@ -74,14 +74,9 @@ export default class ScheduleControllerV2 extends WorklenzControllerBase { .map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`) .join(", "); - const updateQuery = ` - UPDATE public.organization_working_days + const updateQuery = `UPDATE public.organization_working_days SET ${setClause}, updated_at = CURRENT_TIMESTAMP - WHERE organization_id IN ( - SELECT organization_id FROM organizations - WHERE user_id = $1 - ); - `; + WHERE organization_id IN (SELECT id FROM organizations WHERE user_id = $1);`; await db.query(updateQuery, [req.user?.owner_id]); diff --git a/worklenz-backend/src/controllers/task-work-log-controller.ts b/worklenz-backend/src/controllers/task-work-log-controller.ts index 13f69737e..9465f04d2 100644 --- a/worklenz-backend/src/controllers/task-work-log-controller.ts +++ b/worklenz-backend/src/controllers/task-work-log-controller.ts @@ -28,32 +28,50 @@ export default class TaskWorklogController extends WorklenzControllerBase { if (!id) return []; const q = ` - WITH time_logs AS ( - -- - SELECT id, - description, - time_spent, - created_at, - user_id, - logged_by_timer, - (SELECT name FROM users WHERE users.id = task_work_log.user_id) AS user_name, - (SELECT email FROM users WHERE users.id = task_work_log.user_id) AS user_email, - (SELECT avatar_url FROM users WHERE users.id = task_work_log.user_id) AS avatar_url - FROM task_work_log - WHERE task_id = $1 - -- + WITH RECURSIVE task_hierarchy AS ( + -- Base case: Start with the given task + SELECT id, name, 0 as level + FROM tasks + WHERE id = $1 + + UNION ALL + + -- Recursive case: Get all subtasks + SELECT t.id, t.name, th.level + 1 + FROM tasks t + INNER JOIN task_hierarchy th ON t.parent_task_id = th.id + WHERE t.archived IS FALSE + ), + time_logs AS ( + SELECT + twl.id, + twl.description, + twl.time_spent, + twl.created_at, + twl.user_id, + twl.logged_by_timer, + twl.task_id, + th.name AS task_name, + (SELECT name FROM users WHERE users.id = twl.user_id) AS user_name, + (SELECT email FROM users WHERE users.id = twl.user_id) AS user_email, + (SELECT avatar_url FROM users WHERE users.id = twl.user_id) AS avatar_url + FROM task_work_log twl + INNER JOIN task_hierarchy th ON twl.task_id = th.id ) - SELECT id, - time_spent, - description, - created_at, - user_id, - logged_by_timer, - created_at AS start_time, - (created_at + INTERVAL '1 second' * time_spent) AS end_time, - user_name, - user_email, - avatar_url + SELECT + id, + time_spent, + description, + created_at, + user_id, + logged_by_timer, + task_id, + task_name, + created_at AS start_time, + (created_at + INTERVAL '1 second' * time_spent) AS end_time, + user_name, + user_email, + avatar_url FROM time_logs ORDER BY created_at DESC; `; @@ -143,6 +161,7 @@ export default class TaskWorklogController extends WorklenzControllerBase { }; sheet.columns = [ + {header: "Task Name", key: "task_name", width: 30}, {header: "Reporter Name", key: "user_name", width: 25}, {header: "Reporter Email", key: "user_email", width: 25}, {header: "Start Time", key: "start_time", width: 25}, @@ -153,14 +172,15 @@ export default class TaskWorklogController extends WorklenzControllerBase { ]; sheet.getCell("A1").value = metadata.project_name; - sheet.mergeCells("A1:G1"); + sheet.mergeCells("A1:H1"); sheet.getCell("A1").alignment = {horizontal: "center"}; sheet.getCell("A2").value = `${metadata.name} (${exportDate})`; - sheet.mergeCells("A2:G2"); + sheet.mergeCells("A2:H2"); sheet.getCell("A2").alignment = {horizontal: "center"}; sheet.getRow(4).values = [ + "Task Name", "Reporter Name", "Reporter Email", "Start Time", @@ -176,6 +196,7 @@ export default class TaskWorklogController extends WorklenzControllerBase { for (const item of results) { totalLogged += parseFloat((item.time_spent || 0).toString()); const data = { + task_name: item.task_name, user_name: item.user_name, user_email: item.user_email, start_time: moment(item.start_time).add(timezone.hours || 0, "hours").add(timezone.minutes || 0, "minutes").format(timeFormat), @@ -210,6 +231,7 @@ export default class TaskWorklogController extends WorklenzControllerBase { }; sheet.addRow({ + task_name: "", user_name: "", user_email: "", start_time: "Total", @@ -219,7 +241,7 @@ export default class TaskWorklogController extends WorklenzControllerBase { time_spent: formatDuration(moment.duration(totalLogged, "seconds")), }); - sheet.mergeCells(`A${sheet.rowCount}:F${sheet.rowCount}`); + sheet.mergeCells(`A${sheet.rowCount}:G${sheet.rowCount}`); sheet.getCell(`A${sheet.rowCount}`).value = "Total"; sheet.getCell(`A${sheet.rowCount}`).alignment = { diff --git a/worklenz-backend/src/controllers/tasks-controller-base.ts b/worklenz-backend/src/controllers/tasks-controller-base.ts index d2524bad5..bc038deca 100644 --- a/worklenz-backend/src/controllers/tasks-controller-base.ts +++ b/worklenz-backend/src/controllers/tasks-controller-base.ts @@ -50,11 +50,16 @@ export default class TasksControllerBase extends WorklenzControllerBase { task.progress = parseInt(task.progress_value); task.complete_ratio = parseInt(task.progress_value); } - // For tasks with no subtasks and no manual progress, calculate based on time + // For tasks with no subtasks and no manual progress else { - task.progress = task.total_minutes_spent && task.total_minutes - ? ~~(task.total_minutes_spent / task.total_minutes * 100) - : 0; + // Only calculate progress based on time if time-based progress is enabled for the project + if (task.project_use_time_progress && task.total_minutes_spent && task.total_minutes) { + // Cap the progress at 100% to prevent showing more than 100% progress + task.progress = Math.min(~~(task.total_minutes_spent / task.total_minutes * 100), 100); + } else { + // Default to 0% progress when time-based calculation is not enabled + task.progress = 0; + } // Set complete_ratio to match progress task.complete_ratio = task.progress; @@ -76,7 +81,31 @@ export default class TasksControllerBase extends WorklenzControllerBase { task.is_sub_task = !!task.parent_task_id; task.time_spent_string = `${task.time_spent.hours}h ${(task.time_spent.minutes)}m`; - task.total_time_string = `${~~(task.total_minutes / 60)}h ${(task.total_minutes % 60)}m`; + + // Use recursive estimation for parent tasks, own estimation for leaf tasks + const recursiveEstimation = task.recursive_estimation || {}; + const hasSubtasks = (task.sub_tasks_count || 0) > 0; + + let displayMinutes; + if (hasSubtasks) { + // For parent tasks, use recursive estimation (sum of all subtasks) + displayMinutes = recursiveEstimation.recursive_total_minutes || 0; + } else { + // For leaf tasks, use their own estimation + displayMinutes = task.total_minutes || 0; + } + + // Format time string - show "0h" for zero time instead of "0h 0m" + const hours = ~~(displayMinutes / 60); + const minutes = displayMinutes % 60; + + if (displayMinutes === 0) { + task.total_time_string = "0h"; + } else if (minutes === 0) { + task.total_time_string = `${hours}h`; + } else { + task.total_time_string = `${hours}h ${minutes}m`; + } task.name_color = getColor(task.name); task.priority_color = PriorityColorCodes[task.priority_value] || PriorityColorCodes["0"]; diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 6e01c6861..1d1e45e97 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -258,6 +258,7 @@ export default class TasksControllerV2 extends TasksControllerBase { (SELECT id FROM task_priorities WHERE id = t.priority_id) AS priority, (SELECT value FROM task_priorities WHERE id = t.priority_id) AS priority_value, total_minutes, + (SELECT get_task_recursive_estimation(t.id)) AS recursive_estimation, (SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent, created_at, updated_at, diff --git a/worklenz-backend/src/controllers/tasks-controller.ts b/worklenz-backend/src/controllers/tasks-controller.ts index 37ff8f844..53611a5b1 100644 --- a/worklenz-backend/src/controllers/tasks-controller.ts +++ b/worklenz-backend/src/controllers/tasks-controller.ts @@ -427,9 +427,24 @@ export default class TasksController extends TasksControllerBase { task.names = WorklenzControllerBase.createTagList(task.assignees); - const totalMinutes = task.total_minutes; - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; + // Use recursive estimation if task has subtasks, otherwise use own estimation + const recursiveEstimation = task.recursive_estimation || {}; + // Check both the recursive estimation count and the actual database count + const hasSubtasks = (task.sub_tasks_count || 0) > 0; + + let totalMinutes, hours, minutes; + + if (hasSubtasks) { + // For parent tasks, use the sum of all subtasks' estimation (excluding parent's own estimation) + totalMinutes = recursiveEstimation.recursive_total_minutes || 0; + hours = recursiveEstimation.recursive_total_hours || 0; + minutes = recursiveEstimation.recursive_remaining_minutes || 0; + } else { + // For tasks without subtasks, use their own estimation + totalMinutes = task.total_minutes || 0; + hours = Math.floor(totalMinutes / 60); + minutes = totalMinutes % 60; + } task.total_hours = hours; task.total_minutes = minutes; @@ -608,6 +623,18 @@ export default class TasksController extends TasksControllerBase { return res.status(200).send(new ServerResponse(true, null)); } + @HandleExceptions() + public static async resetParentTaskEstimations(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const q = `SELECT reset_all_parent_task_estimations() AS updated_count;`; + const result = await db.query(q); + const [data] = result.rows; + + return res.status(200).send(new ServerResponse(true, { + message: `Reset estimation for ${data.updated_count} parent tasks`, + updated_count: data.updated_count + })); + } + @HandleExceptions() public static async bulkAssignMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const { tasks, members, project_id } = req.body; diff --git a/worklenz-backend/src/middlewares/session-middleware.ts b/worklenz-backend/src/middlewares/session-middleware.ts index fea600181..92844ea68 100644 --- a/worklenz-backend/src/middlewares/session-middleware.ts +++ b/worklenz-backend/src/middlewares/session-middleware.ts @@ -8,8 +8,8 @@ const pgSession = require("connect-pg-simple")(session); export default session({ name: process.env.SESSION_NAME || "worklenz.sid", secret: process.env.SESSION_SECRET || "development-secret-key", - proxy: true, - resave: false, + proxy: false, + resave: true, saveUninitialized: false, rolling: true, store: new pgSession({ @@ -18,9 +18,8 @@ export default session({ }), cookie: { path: "/", - secure: isProduction(), // Use secure cookies in production httpOnly: true, - sameSite: "lax", // Standard setting for same-origin requests + secure: false, maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days } }); \ No newline at end of file diff --git a/worklenz-backend/src/middlewares/validators/ratecard-body-validator.ts b/worklenz-backend/src/middlewares/validators/ratecard-body-validator.ts new file mode 100644 index 000000000..04c4e4996 --- /dev/null +++ b/worklenz-backend/src/middlewares/validators/ratecard-body-validator.ts @@ -0,0 +1,15 @@ +import {NextFunction} from "express"; + +import {IWorkLenzRequest} from "../../interfaces/worklenz-request"; +import {IWorkLenzResponse} from "../../interfaces/worklenz-response"; +import {ServerResponse} from "../../models/server-response"; + +export default function (req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction): IWorkLenzResponse | void { + const {name} = req.body; + if (!name || name.trim() === "") + return res.status(200).send(new ServerResponse(false, null, "Name is required")); + + req.body.name = req.body.name.trim(); + + return next(); +} diff --git a/worklenz-backend/src/passport/deserialize.ts b/worklenz-backend/src/passport/deserialize.ts index bbbd5352b..56f396215 100644 --- a/worklenz-backend/src/passport/deserialize.ts +++ b/worklenz-backend/src/passport/deserialize.ts @@ -30,6 +30,7 @@ export async function deserialize(user: { id: string | null }, done: IDeserializ const excludedSubscriptionTypes = ["TRIAL", "PADDLE"]; const q = `SELECT deserialize_user($1) AS user;`; const result = await db.query(q, [id]); + if (result.rows.length) { const [data] = result.rows; if (data?.user) { diff --git a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts index d71c4a366..719d41426 100644 --- a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts +++ b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts @@ -44,7 +44,6 @@ async function handleLogin(req: Request, email: string, password: string, done: req.flash(ERROR_KEY, errorMsg); return done(null, false); } catch (error) { - console.error("Login error:", error); log_error(error, req.body); return done(error); } diff --git a/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts b/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts index 563950667..0c5cf39af 100644 --- a/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts +++ b/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts @@ -47,41 +47,55 @@ async function handleSignUp(req: Request, email: string, password: string, done: // team = Invited team_id if req.body.from_invitation is true const {name, team_name, team_member_id, team_id, timezone} = req.body; - if (!team_name) return done(null, null, req.flash(ERROR_KEY, "Team name is required")); + if (!team_name) { + req.flash(ERROR_KEY, "Team name is required"); + return done(null, null, {message: "Team name is required"}); + } const googleAccountFound = await isGoogleAccountFound(email); - if (googleAccountFound) - return done(null, null, req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`)); + if (googleAccountFound) { + req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`); + return done(null, null, {message: `${req.body.email} is already linked with a Google account.`}); + } try { const user = await registerUser(password, team_id, name, team_name, email, timezone, team_member_id); sendWelcomeEmail(email, name); - return done(null, user, req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification.")); + req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification."); + return done(null, user, {message: "Registration successful. Please check your email for verification."}); } catch (error: any) { const message = (error?.message) || ""; if (message === "ERROR_INVALID_JOINING_EMAIL") { - return done(null, null, req.flash(ERROR_KEY, `No invitations found for email ${req.body.email}.`)); + req.flash(ERROR_KEY, `No invitations found for email ${req.body.email}.`); + return done(null, null, {message: `No invitations found for email ${req.body.email}.`}); } // if error.message is "email already exists" then it should have the email address in the error message after ":". if (message.includes("EMAIL_EXISTS_ERROR") || error.constraint === "users_google_id_uindex") { const [, value] = error.message.split(":"); - return done(null, null, req.flash(ERROR_KEY, `Worklenz account already exists for email ${value}.`)); + const errorMsg = `Worklenz account already exists for email ${value}.`; + req.flash(ERROR_KEY, errorMsg); + return done(null, null, {message: errorMsg}); } if (message.includes("TEAM_NAME_EXISTS_ERROR")) { const [, value] = error.message.split(":"); - return done(null, null, req.flash(ERROR_KEY, `Team name "${value}" already exists. Please choose a different team name.`)); + const errorMsg = `Team name "${value}" already exists. Please choose a different team name.`; + req.flash(ERROR_KEY, errorMsg); + return done(null, null, {message: errorMsg}); } // The Team name is already taken. if (error.constraint === "teams_url_uindex" || error.constraint === "teams_name_uindex") { - return done(null, null, req.flash(ERROR_KEY, `Team name "${team_name}" is already taken. Please choose a different team name.`)); + const errorMsg = `Team name "${team_name}" is already taken. Please choose a different team name.`; + req.flash(ERROR_KEY, errorMsg); + return done(null, null, {message: errorMsg}); } log_error(error, req.body); - return done(null, null, req.flash(ERROR_KEY, DEFAULT_ERROR_MESSAGE)); + req.flash(ERROR_KEY, DEFAULT_ERROR_MESSAGE); + return done(null, null, {message: DEFAULT_ERROR_MESSAGE}); } } diff --git a/worklenz-backend/src/routes/apis/index.ts b/worklenz-backend/src/routes/apis/index.ts index 5a2019c86..0cfc98c68 100644 --- a/worklenz-backend/src/routes/apis/index.ts +++ b/worklenz-backend/src/routes/apis/index.ts @@ -1,120 +1,128 @@ -import express from "express"; - -import AccessControlsController from "../../controllers/access-controls-controller"; -import AuthController from "../../controllers/auth-controller"; -import LogsController from "../../controllers/logs-controller"; -import OverviewController from "../../controllers/overview-controller"; -import TaskPrioritiesController from "../../controllers/task-priorities-controller"; - -import attachmentsApiRouter from "./attachments-api-router"; -import clientsApiRouter from "./clients-api-router"; -import jobTitlesApiRouter from "./job-titles-api-router"; -import notificationsApiRouter from "./notifications-api-router"; -import personalOverviewApiRouter from "./personal-overview-api-router"; -import projectMembersApiRouter from "./project-members-api-router"; -import projectsApiRouter from "./projects-api-router"; -import settingsApiRouter from "./settings-api-router"; -import statusesApiRouter from "./statuses-api-router"; -import subTasksApiRouter from "./sub-tasks-api-router"; -import taskCommentsApiRouter from "./task-comments-api-router"; -import taskWorkLogApiRouter from "./task-work-log-api-router"; -import tasksApiRouter from "./tasks-api-router"; -import teamMembersApiRouter from "./team-members-api-router"; -import teamsApiRouter from "./teams-api-router"; -import timezonesApiRouter from "./timezones-api-router"; -import todoListApiRouter from "./todo-list-api-router"; -import projectStatusesApiRouter from "./project-statuses-api-router"; -import labelsApiRouter from "./labels-api-router"; -import sharedProjectsApiRouter from "./shared-projects-api-router"; -import resourceAllocationApiRouter from "./resource-allocation-api-router"; -import taskTemplatesApiRouter from "./task-templates-api-router"; -import projectInsightsApiRouter from "./project-insights-api-router"; -import passwordValidator from "../../middlewares/validators/password-validator"; -import adminCenterApiRouter from "./admin-center-api-router"; -import reportingApiRouter from "./reporting-api-router"; -import activityLogsApiRouter from "./activity-logs-api-router"; -import safeControllerFunction from "../../shared/safe-controller-function"; -import projectFoldersApiRouter from "./project-folders-api-router"; -import taskPhasesApiRouter from "./task-phases-api-router"; -import projectCategoriesApiRouter from "./project-categories-api-router"; -import homePageApiRouter from "./home-page-api-router"; -import ganttApiRouter from "./gantt-api-router"; -import projectCommentsApiRouter from "./project-comments-api-router"; -import reportingExportApiRouter from "./reporting-export-api-router"; -import projectHealthsApiRouter from "./project-healths-api-router"; -import ptTasksApiRouter from "./pt-tasks-api-router"; -import projectTemplatesApiRouter from "./project-templates-api"; -import ptTaskPhasesApiRouter from "./pt_task-phases-api-router"; -import ptStatusesApiRouter from "./pt-statuses-api-router"; -import workloadApiRouter from "./gannt-apis/workload-api-router"; -import roadmapApiRouter from "./gannt-apis/roadmap-api-router"; -import scheduleApiRouter from "./gannt-apis/schedule-api-router"; -import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router"; -import projectManagerApiRouter from "./project-managers-api-router"; - -import billingApiRouter from "./billing-api-router"; -import taskDependenciesApiRouter from "./task-dependencies-api-router"; - -import taskRecurringApiRouter from "./task-recurring-api-router"; - import customColumnsApiRouter from "./custom-columns-api-router"; +import express from "express"; + +import AccessControlsController from "../../controllers/access-controls-controller"; +import AuthController from "../../controllers/auth-controller"; +import LogsController from "../../controllers/logs-controller"; +import OverviewController from "../../controllers/overview-controller"; +import TaskPrioritiesController from "../../controllers/task-priorities-controller"; + +import attachmentsApiRouter from "./attachments-api-router"; +import clientsApiRouter from "./clients-api-router"; +import jobTitlesApiRouter from "./job-titles-api-router"; +import notificationsApiRouter from "./notifications-api-router"; +import personalOverviewApiRouter from "./personal-overview-api-router"; +import projectMembersApiRouter from "./project-members-api-router"; +import projectsApiRouter from "./projects-api-router"; +import settingsApiRouter from "./settings-api-router"; +import statusesApiRouter from "./statuses-api-router"; +import subTasksApiRouter from "./sub-tasks-api-router"; +import taskCommentsApiRouter from "./task-comments-api-router"; +import taskWorkLogApiRouter from "./task-work-log-api-router"; +import tasksApiRouter from "./tasks-api-router"; +import teamMembersApiRouter from "./team-members-api-router"; +import teamsApiRouter from "./teams-api-router"; +import timezonesApiRouter from "./timezones-api-router"; +import todoListApiRouter from "./todo-list-api-router"; +import projectStatusesApiRouter from "./project-statuses-api-router"; +import labelsApiRouter from "./labels-api-router"; +import sharedProjectsApiRouter from "./shared-projects-api-router"; +import resourceAllocationApiRouter from "./resource-allocation-api-router"; +import taskTemplatesApiRouter from "./task-templates-api-router"; +import projectInsightsApiRouter from "./project-insights-api-router"; +import passwordValidator from "../../middlewares/validators/password-validator"; +import adminCenterApiRouter from "./admin-center-api-router"; +import reportingApiRouter from "./reporting-api-router"; +import activityLogsApiRouter from "./activity-logs-api-router"; +import safeControllerFunction from "../../shared/safe-controller-function"; +import projectFoldersApiRouter from "./project-folders-api-router"; +import taskPhasesApiRouter from "./task-phases-api-router"; +import projectCategoriesApiRouter from "./project-categories-api-router"; +import homePageApiRouter from "./home-page-api-router"; +import ganttApiRouter from "./gantt-api-router"; +import projectCommentsApiRouter from "./project-comments-api-router"; +import reportingExportApiRouter from "./reporting-export-api-router"; +import projectHealthsApiRouter from "./project-healths-api-router"; +import ptTasksApiRouter from "./pt-tasks-api-router"; +import projectTemplatesApiRouter from "./project-templates-api"; +import ptTaskPhasesApiRouter from "./pt_task-phases-api-router"; +import ptStatusesApiRouter from "./pt-statuses-api-router"; +import workloadApiRouter from "./gannt-apis/workload-api-router"; +import roadmapApiRouter from "./gannt-apis/roadmap-api-router"; +import scheduleApiRouter from "./gannt-apis/schedule-api-router"; +import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router"; +import projectManagerApiRouter from "./project-managers-api-router"; + +import billingApiRouter from "./billing-api-router"; +import taskDependenciesApiRouter from "./task-dependencies-api-router"; + +import taskRecurringApiRouter from "./task-recurring-api-router"; + +import customColumnsApiRouter from "./custom-columns-api-router"; +import ratecardApiRouter from "./ratecard-api-router"; +import projectRatecardApiRouter from "./project-ratecard-api-router"; +import projectFinanceApiRouter from "./project-finance-api-router"; + +const api = express.Router(); + +api.use("/projects", projectsApiRouter); +api.use("/team-members", teamMembersApiRouter); +api.use("/job-titles", jobTitlesApiRouter); +api.use("/clients", clientsApiRouter); +api.use("/rate-cards", ratecardApiRouter); +api.use("/project-rate-cards", projectRatecardApiRouter); +api.use("/teams", teamsApiRouter); +api.use("/tasks", tasksApiRouter); +api.use("/settings", settingsApiRouter); +api.use("/personal-overview", personalOverviewApiRouter); +api.use("/statuses", statusesApiRouter); +api.use("/todo-list", todoListApiRouter); +api.use("/notifications", notificationsApiRouter); +api.use("/attachments", attachmentsApiRouter); +api.use("/sub-tasks", subTasksApiRouter); +api.use("/project-members", projectMembersApiRouter); +api.use("/task-time-log", taskWorkLogApiRouter); +api.use("/task-comments", taskCommentsApiRouter); +api.use("/timezones", timezonesApiRouter); +api.use("/project-statuses", projectStatusesApiRouter); +api.use("/labels", labelsApiRouter); +api.use("/resource-allocation", resourceAllocationApiRouter); +api.use("/shared/projects", sharedProjectsApiRouter); +api.use("/task-templates", taskTemplatesApiRouter); +api.use("/project-insights", projectInsightsApiRouter); +api.use("/admin-center", adminCenterApiRouter); +api.use("/reporting", reportingApiRouter); +api.use("/activity-logs", activityLogsApiRouter); +api.use("/projects-folders", projectFoldersApiRouter); +api.use("/task-phases", taskPhasesApiRouter); +api.use("/project-categories", projectCategoriesApiRouter); +api.use("/home", homePageApiRouter); +api.use("/gantt", ganttApiRouter); +api.use("/project-comments", projectCommentsApiRouter); +api.use("/reporting-export", reportingExportApiRouter); +api.use("/project-healths", projectHealthsApiRouter); +api.use("/project-templates", projectTemplatesApiRouter); +api.use("/pt-tasks", ptTasksApiRouter); +api.use("/pt-task-phases", ptTaskPhasesApiRouter); +api.use("/pt-statuses", ptStatusesApiRouter); +api.use("/workload-gannt", workloadApiRouter); +api.use("/roadmap-gannt", roadmapApiRouter); +api.use("/schedule-gannt", scheduleApiRouter); +api.use("/schedule-gannt-v2", scheduleApiV2Router); +api.use("/project-managers", projectManagerApiRouter); + +api.get("/overview/:id", safeControllerFunction(OverviewController.getById)); +api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get)); +api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword)); +api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles)); +api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog)); + +api.use("/billing", billingApiRouter); +api.use("/task-dependencies", taskDependenciesApiRouter); + +api.use("/task-recurring", taskRecurringApiRouter); -const api = express.Router(); - -api.use("/projects", projectsApiRouter); -api.use("/team-members", teamMembersApiRouter); -api.use("/job-titles", jobTitlesApiRouter); -api.use("/clients", clientsApiRouter); -api.use("/teams", teamsApiRouter); -api.use("/tasks", tasksApiRouter); -api.use("/settings", settingsApiRouter); -api.use("/personal-overview", personalOverviewApiRouter); -api.use("/statuses", statusesApiRouter); -api.use("/todo-list", todoListApiRouter); -api.use("/notifications", notificationsApiRouter); -api.use("/attachments", attachmentsApiRouter); -api.use("/sub-tasks", subTasksApiRouter); -api.use("/project-members", projectMembersApiRouter); -api.use("/task-time-log", taskWorkLogApiRouter); -api.use("/task-comments", taskCommentsApiRouter); -api.use("/timezones", timezonesApiRouter); -api.use("/project-statuses", projectStatusesApiRouter); -api.use("/labels", labelsApiRouter); -api.use("/resource-allocation", resourceAllocationApiRouter); -api.use("/shared/projects", sharedProjectsApiRouter); -api.use("/task-templates", taskTemplatesApiRouter); -api.use("/project-insights", projectInsightsApiRouter); -api.use("/admin-center", adminCenterApiRouter); -api.use("/reporting", reportingApiRouter); -api.use("/activity-logs", activityLogsApiRouter); -api.use("/projects-folders", projectFoldersApiRouter); -api.use("/task-phases", taskPhasesApiRouter); -api.use("/project-categories", projectCategoriesApiRouter); -api.use("/home", homePageApiRouter); -api.use("/gantt", ganttApiRouter); -api.use("/project-comments", projectCommentsApiRouter); -api.use("/reporting-export", reportingExportApiRouter); -api.use("/project-healths", projectHealthsApiRouter); -api.use("/project-templates", projectTemplatesApiRouter); -api.use("/pt-tasks", ptTasksApiRouter); -api.use("/pt-task-phases", ptTaskPhasesApiRouter); -api.use("/pt-statuses", ptStatusesApiRouter); -api.use("/workload-gannt", workloadApiRouter); -api.use("/roadmap-gannt", roadmapApiRouter); -api.use("/schedule-gannt", scheduleApiRouter); -api.use("/schedule-gannt-v2", scheduleApiV2Router); -api.use("/project-managers", projectManagerApiRouter); - -api.get("/overview/:id", safeControllerFunction(OverviewController.getById)); -api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get)); -api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword)); -api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles)); -api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog)); - -api.use("/billing", billingApiRouter); -api.use("/task-dependencies", taskDependenciesApiRouter); - -api.use("/task-recurring", taskRecurringApiRouter); - api.use("/custom-columns", customColumnsApiRouter); -export default api; +api.use("/project-finance", projectFinanceApiRouter); + +export default api; diff --git a/worklenz-backend/src/routes/apis/project-finance-api-router.ts b/worklenz-backend/src/routes/apis/project-finance-api-router.ts new file mode 100644 index 000000000..7ae0dccac --- /dev/null +++ b/worklenz-backend/src/routes/apis/project-finance-api-router.ts @@ -0,0 +1,20 @@ +import express from "express"; + +import ProjectfinanceController from "../../controllers/project-finance-controller"; +import idParamValidator from "../../middlewares/validators/id-param-validator"; +import safeControllerFunction from "../../shared/safe-controller-function"; + +const projectFinanceApiRouter = express.Router(); + +projectFinanceApiRouter.get("/project/:project_id/tasks", ProjectfinanceController.getTasks); +projectFinanceApiRouter.get("/project/:project_id/tasks/:parent_task_id/subtasks", ProjectfinanceController.getSubTasks); +projectFinanceApiRouter.get( + "/task/:id/breakdown", + idParamValidator, + safeControllerFunction(ProjectfinanceController.getTaskBreakdown) +); +projectFinanceApiRouter.put("/task/:task_id/fixed-cost", ProjectfinanceController.updateTaskFixedCost); +projectFinanceApiRouter.put("/project/:project_id/currency", ProjectfinanceController.updateProjectCurrency); +projectFinanceApiRouter.get("/project/:project_id/export", ProjectfinanceController.exportFinanceData); + +export default projectFinanceApiRouter; \ No newline at end of file diff --git a/worklenz-backend/src/routes/apis/project-ratecard-api-router.ts b/worklenz-backend/src/routes/apis/project-ratecard-api-router.ts new file mode 100644 index 000000000..09462c4d4 --- /dev/null +++ b/worklenz-backend/src/routes/apis/project-ratecard-api-router.ts @@ -0,0 +1,69 @@ +import express from "express"; +import ProjectRateCardController from "../../controllers/project-ratecard-controller"; +import idParamValidator from "../../middlewares/validators/id-param-validator"; +import safeControllerFunction from "../../shared/safe-controller-function"; +import projectManagerValidator from "../../middlewares/validators/project-manager-validator"; + +const projectRatecardApiRouter = express.Router(); + +// Insert multiple roles for a project +projectRatecardApiRouter.post( + "/", + projectManagerValidator, + safeControllerFunction(ProjectRateCardController.createMany) +); +// Insert a single role for a project +projectRatecardApiRouter.post( + "/create-project-rate-card-role", + projectManagerValidator, + safeControllerFunction(ProjectRateCardController.createOne) +); + +// Get all roles for a project +projectRatecardApiRouter.get( + "/project/:project_id", + safeControllerFunction(ProjectRateCardController.getByProjectId) +); + +// Get a single role by id +projectRatecardApiRouter.get( + "/:id", + idParamValidator, + safeControllerFunction(ProjectRateCardController.getById) +); + +// Update a single role by id +projectRatecardApiRouter.put( + "/:id", + idParamValidator, + safeControllerFunction(ProjectRateCardController.updateById) +); + +// Update all roles for a project (delete then insert) +projectRatecardApiRouter.put( + "/project/:project_id", + safeControllerFunction(ProjectRateCardController.updateByProjectId) +); + +// Update project member rate card role +projectRatecardApiRouter.put( + "/project/:project_id/members/:id/rate-card-role", + idParamValidator, + projectManagerValidator, + safeControllerFunction(ProjectRateCardController.updateProjectMemberByProjectIdAndMemberId) +); + +// Delete a single role by id +projectRatecardApiRouter.delete( + "/:id", + idParamValidator, + safeControllerFunction(ProjectRateCardController.deleteById) +); + +// Delete all roles for a project +projectRatecardApiRouter.delete( + "/project/:project_id", + safeControllerFunction(ProjectRateCardController.deleteByProjectId) +); + +export default projectRatecardApiRouter; diff --git a/worklenz-backend/src/routes/apis/ratecard-api-router.ts b/worklenz-backend/src/routes/apis/ratecard-api-router.ts new file mode 100644 index 000000000..8ddbf2dd0 --- /dev/null +++ b/worklenz-backend/src/routes/apis/ratecard-api-router.ts @@ -0,0 +1,48 @@ +import express from "express"; + +import RateCardController from "../../controllers/ratecard-controller"; + + +import idParamValidator from "../../middlewares/validators/id-param-validator"; +import teamOwnerOrAdminValidator from "../../middlewares/validators/team-owner-or-admin-validator"; +import safeControllerFunction from "../../shared/safe-controller-function"; +import projectManagerValidator from "../../middlewares/validators/project-manager-validator"; +import ratecardBodyValidator from "../../middlewares/validators/ratecard-body-validator"; + +const ratecardApiRouter = express.Router(); + +ratecardApiRouter.post( + "/", + projectManagerValidator, + ratecardBodyValidator, + safeControllerFunction(RateCardController.create) +); + +ratecardApiRouter.get( + "/", + safeControllerFunction(RateCardController.get) +); + +ratecardApiRouter.get( + "/:id", + teamOwnerOrAdminValidator, + idParamValidator, + safeControllerFunction(RateCardController.getById) +); + +ratecardApiRouter.put( + "/:id", + teamOwnerOrAdminValidator, + ratecardBodyValidator, + idParamValidator, + safeControllerFunction(RateCardController.update) +); + +ratecardApiRouter.delete( + "/:id", + teamOwnerOrAdminValidator, + idParamValidator, + safeControllerFunction(RateCardController.deleteById) +); + +export default ratecardApiRouter; diff --git a/worklenz-backend/src/routes/apis/tasks-api-router.ts b/worklenz-backend/src/routes/apis/tasks-api-router.ts index bb6af547e..006229f43 100644 --- a/worklenz-backend/src/routes/apis/tasks-api-router.ts +++ b/worklenz-backend/src/routes/apis/tasks-api-router.ts @@ -69,4 +69,7 @@ tasksApiRouter.put("/labels/:id", idParamValidator, safeControllerFunction(Tasks // Add custom column value update route tasksApiRouter.put("/:taskId/custom-column", TasksControllerV2.updateCustomColumnValue); +// Add route to reset parent task estimations +tasksApiRouter.post("/reset-parent-estimations", safeControllerFunction(TasksController.resetParentTaskEstimations)); + export default tasksApiRouter; diff --git a/worklenz-backend/src/shared/constants.ts b/worklenz-backend/src/shared/constants.ts index c814c6030..f9a9c832d 100644 --- a/worklenz-backend/src/shared/constants.ts +++ b/worklenz-backend/src/shared/constants.ts @@ -166,7 +166,9 @@ export const UNMAPPED = "Unmapped"; export const DATE_RANGES = { YESTERDAY: "YESTERDAY", + LAST_7_DAYS: "LAST_7_DAYS", LAST_WEEK: "LAST_WEEK", + LAST_30_DAYS: "LAST_30_DAYS", LAST_MONTH: "LAST_MONTH", LAST_QUARTER: "LAST_QUARTER", ALL_TIME: "ALL_TIME" diff --git a/worklenz-backend/src/socket.io/commands/on-quick-task.ts b/worklenz-backend/src/socket.io/commands/on-quick-task.ts index 859cbf587..80e9f3816 100644 --- a/worklenz-backend/src/socket.io/commands/on-quick-task.ts +++ b/worklenz-backend/src/socket.io/commands/on-quick-task.ts @@ -1,11 +1,11 @@ -import {Server, Socket} from "socket.io"; +import { Server, Socket } from "socket.io"; import db from "../../config/db"; -import {getColor, toMinutes} from "../../shared/utils"; -import {SocketEvents} from "../events"; +import { getColor, toMinutes } from "../../shared/utils"; +import { SocketEvents } from "../events"; -import {log_error, notifyProjectUpdates} from "../util"; +import { log_error, notifyProjectUpdates } from "../util"; import TasksControllerV2 from "../../controllers/tasks-controller-v2"; -import {TASK_STATUS_COLOR_ALPHA, UNMAPPED} from "../../shared/constants"; +import { TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../../shared/constants"; import moment from "moment"; import momentTime from "moment-timezone"; import { logEndDateChange, logStartDateChange, logStatusChange } from "../../services/activity-logs/activity-logs.service"; @@ -18,8 +18,9 @@ export async function getTaskCompleteInfo(task: any) { const [d2] = result2.rows; task.completed_count = d2.res.total_completed || 0; - if (task.sub_tasks_count > 0) + if (task.sub_tasks_count > 0 && d2.res.total_tasks > 0) { task.sub_tasks_count = d2.res.total_tasks; + } return task; } @@ -97,8 +98,8 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string) logEndDateChange({ task_id: d.task.id, socket, - new_value: body.time_zone && d.task.end_date ? momentTime.tz(d.task.end_date, `${body.time_zone}`) : d.task.end_date, - old_value: null + new_value: body.time_zone && d.task.end_date ? momentTime.tz(d.task.end_date, `${body.time_zone}`) : d.task.end_date, + old_value: null }); } diff --git a/worklenz-frontend/index.html b/worklenz-frontend/index.html index ba93ca2ca..a30049fca 100644 --- a/worklenz-frontend/index.html +++ b/worklenz-frontend/index.html @@ -10,6 +10,8 @@ + + Worklenz diff --git a/worklenz-frontend/jest.config.js b/worklenz-frontend/jest.config.js index f3d2d0274..167bb4d6b 100644 --- a/worklenz-frontend/jest.config.js +++ b/worklenz-frontend/jest.config.js @@ -1,4 +1,4 @@ -module.exports = { +export default { setupFilesAfterEnv: ['/src/setupTests.ts'], moduleNameMapper: { '^@/(.*)$': '/src/$1', diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json index 50940a18e..024a1b090 100644 --- a/worklenz-frontend/package-lock.json +++ b/worklenz-frontend/package-lock.json @@ -13,8 +13,8 @@ "@ant-design/icons": "^5.4.0", "@ant-design/pro-components": "^2.7.19", "@dnd-kit/core": "^6.3.1", - "@dnd-kit/modifiers": "^9.0.0", - "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/modifiers": "^6.0.1", + "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@paddle/paddle-js": "^1.3.3", @@ -49,7 +49,8 @@ "react-window": "^1.8.11", "socket.io-client": "^4.8.1", "tinymce": "^7.7.2", - "web-vitals": "^4.2.4" + "web-vitals": "^4.2.4", + "wx-react-gantt": "^1.3.1" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.3", @@ -76,9 +77,9 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.2.tgz", - "integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz", + "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==", "dev": true, "license": "MIT" }, @@ -110,9 +111,9 @@ } }, "node_modules/@ant-design/colors": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.0.tgz", - "integrity": "sha512-bjTObSnZ9C/O8MB/B4OUtd/q9COomuJAR2SYfhxLyHvCKn4EKwCN3e+fWGMo7H5InAyV0wL17jdE9ALrdOW/6A==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", "license": "MIT", "dependencies": { "@ant-design/fast-color": "^2.0.6" @@ -412,12 +413,6 @@ "shallowequal": "^1.1.0" } }, - "node_modules/@ant-design/pro-list/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/@ant-design/pro-provider": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/@ant-design/pro-provider/-/pro-provider-2.15.4.tgz", @@ -483,34 +478,6 @@ "react-dom": ">=17.0.0" } }, - "node_modules/@ant-design/pro-table/node_modules/@dnd-kit/modifiers": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-6.0.1.tgz", - "integrity": "sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==", - "license": "MIT", - "dependencies": { - "@dnd-kit/utilities": "^3.2.1", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "@dnd-kit/core": "^6.0.6", - "react": ">=16.8.0" - } - }, - "node_modules/@ant-design/pro-table/node_modules/@dnd-kit/sortable": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz", - "integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==", - "license": "MIT", - "dependencies": { - "@dnd-kit/utilities": "^3.2.0", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "@dnd-kit/core": "^6.0.7", - "react": ">=16.8.0" - } - }, "node_modules/@ant-design/pro-utils": { "version": "2.17.0", "resolved": "https://registry.npmjs.org/@ant-design/pro-utils/-/pro-utils-2.17.0.tgz", @@ -551,23 +518,23 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", - "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.3.tgz", + "integrity": "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw==", "dev": true, "license": "MIT", "engines": { @@ -575,22 +542,22 @@ } }, "node_modules/@babel/core": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", - "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", + "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.10", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.10", - "@babel/types": "^7.26.10", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.4", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.27.4", + "@babel/types": "^7.27.3", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -613,13 +580,13 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz", - "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.3.tgz", + "integrity": "sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0", + "@babel/parser": "^7.27.3", + "@babel/types": "^7.27.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -629,14 +596,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz", - "integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.26.8", - "@babel/helper-validator-option": "^7.25.9", + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -646,28 +613,28 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" }, "engines": { "node": ">=6.9.0" @@ -677,9 +644,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", - "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "dev": true, "license": "MIT", "engines": { @@ -687,27 +654,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", "engines": { @@ -715,26 +682,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", - "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.4.tgz", + "integrity": "sha512-Y+bO6U+I7ZKaM5G5rDUZiYfUvQPUibYmAFe7EnKdnKBbVXDZxvp+MWOH5gYciY0EPk4EScsuFMQBbEfpdRKSCQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.4.tgz", + "integrity": "sha512-BRmLHGwpUqLFR2jzx9orBuX/ABDkj2jLKOXrHDTN2aOKL+jFDDKaRNo9nyYsIl9h/UE/7lMKdDjKQQyxKKDZ7g==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.0" + "@babel/types": "^7.27.3" }, "bin": { "parser": "bin/babel-parser.js" @@ -744,13 +711,13 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", - "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -760,13 +727,13 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", - "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -776,42 +743,39 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", - "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.4.tgz", + "integrity": "sha512-t3yaEOuGu9NlIZ+hIeGbBjFtZT7j2cb2tg0fuaJKeGotchRjjLfrBA9Kwf8quhpP1EUuxModQg04q/mBwyg8uA==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", - "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz", - "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", + "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.27.0", - "@babel/parser": "^7.27.0", - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -820,13 +784,13 @@ } }, "node_modules/@babel/types": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.3.tgz", + "integrity": "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -882,30 +846,30 @@ } }, "node_modules/@dnd-kit/modifiers": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", - "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-6.0.1.tgz", + "integrity": "sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==", "license": "MIT", "dependencies": { - "@dnd-kit/utilities": "^3.2.2", + "@dnd-kit/utilities": "^3.2.1", "tslib": "^2.0.0" }, "peerDependencies": { - "@dnd-kit/core": "^6.3.0", + "@dnd-kit/core": "^6.0.6", "react": ">=16.8.0" } }, "node_modules/@dnd-kit/sortable": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", - "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz", + "integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==", "license": "MIT", "dependencies": { - "@dnd-kit/utilities": "^3.2.2", + "@dnd-kit/utilities": "^3.2.0", "tslib": "^2.0.0" }, "peerDependencies": { - "@dnd-kit/core": "^6.3.0", + "@dnd-kit/core": "^6.0.7", "react": ">=16.8.0" } }, @@ -1066,9 +1030,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", - "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", "cpu": [ "ppc64" ], @@ -1083,9 +1047,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz", - "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", "cpu": [ "arm" ], @@ -1100,9 +1064,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz", - "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", "cpu": [ "arm64" ], @@ -1117,9 +1081,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz", - "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", "cpu": [ "x64" ], @@ -1134,9 +1098,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz", - "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", "cpu": [ "arm64" ], @@ -1151,9 +1115,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz", - "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", "cpu": [ "x64" ], @@ -1168,9 +1132,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz", - "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", "cpu": [ "arm64" ], @@ -1185,9 +1149,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz", - "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", "cpu": [ "x64" ], @@ -1202,9 +1166,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz", - "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", "cpu": [ "arm" ], @@ -1219,9 +1183,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz", - "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", "cpu": [ "arm64" ], @@ -1236,9 +1200,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz", - "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", "cpu": [ "ia32" ], @@ -1253,9 +1217,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz", - "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", "cpu": [ "loong64" ], @@ -1270,9 +1234,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz", - "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", "cpu": [ "mips64el" ], @@ -1287,9 +1251,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz", - "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", "cpu": [ "ppc64" ], @@ -1304,9 +1268,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz", - "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", "cpu": [ "riscv64" ], @@ -1321,9 +1285,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz", - "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", "cpu": [ "s390x" ], @@ -1338,9 +1302,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz", - "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", "cpu": [ "x64" ], @@ -1355,9 +1319,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz", - "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", "cpu": [ "arm64" ], @@ -1372,9 +1336,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz", - "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", "cpu": [ "x64" ], @@ -1389,9 +1353,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz", - "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", "cpu": [ "arm64" ], @@ -1406,9 +1370,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz", - "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", "cpu": [ "x64" ], @@ -1423,9 +1387,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz", - "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", "cpu": [ "x64" ], @@ -1440,9 +1404,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz", - "integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", "cpu": [ "arm64" ], @@ -1457,9 +1421,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz", - "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", "cpu": [ "ia32" ], @@ -1474,9 +1438,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz", - "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", "cpu": [ "x64" ], @@ -1612,9 +1576,9 @@ } }, "node_modules/@paddle/paddle-js": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@paddle/paddle-js/-/paddle-js-1.4.0.tgz", - "integrity": "sha512-pX6Yx+RswB1rHMuYl8RKcAAVZhVJ6nd5f8w8l4kVM63pM3HNeQ5/Xuk4sK/X9P5fUE2dmN0mTti7+gZ8cZtqvg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@paddle/paddle-js/-/paddle-js-1.4.1.tgz", + "integrity": "sha512-GKuXVnUAIGq4H1AxrPRRMZXl+pTSGiKMStpRlvF6+dv03BwhkqbyHJJZ39e6bMquVbYSa33/9cu6fuW8pie8aQ==", "license": "Apache-2.0" }, "node_modules/@pkgjs/parseargs": { @@ -1778,11 +1742,13 @@ } }, "node_modules/@reduxjs/toolkit": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.6.1.tgz", - "integrity": "sha512-SSlIqZNYhqm/oMkXbtofwZSt9lrncblzo6YcZ9zoX+zLngRBrCOjK4lNLdkNucJF58RHOWrD9txT3bT3piH7Zw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", + "integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==", "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", "immer": "^10.0.3", "redux": "^5.0.1", "redux-thunk": "^3.1.0", @@ -1810,10 +1776,17 @@ "node": ">=14.0.0" } }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.9", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz", + "integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz", - "integrity": "sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.1.tgz", + "integrity": "sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==", "cpu": [ "arm" ], @@ -1825,9 +1798,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.39.0.tgz", - "integrity": "sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.1.tgz", + "integrity": "sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA==", "cpu": [ "arm64" ], @@ -1839,9 +1812,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.39.0.tgz", - "integrity": "sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.1.tgz", + "integrity": "sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w==", "cpu": [ "arm64" ], @@ -1853,9 +1826,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.39.0.tgz", - "integrity": "sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.1.tgz", + "integrity": "sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg==", "cpu": [ "x64" ], @@ -1867,9 +1840,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.39.0.tgz", - "integrity": "sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.1.tgz", + "integrity": "sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg==", "cpu": [ "arm64" ], @@ -1881,9 +1854,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.39.0.tgz", - "integrity": "sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.1.tgz", + "integrity": "sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA==", "cpu": [ "x64" ], @@ -1895,9 +1868,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.39.0.tgz", - "integrity": "sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.1.tgz", + "integrity": "sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==", "cpu": [ "arm" ], @@ -1909,9 +1882,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.39.0.tgz", - "integrity": "sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.1.tgz", + "integrity": "sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==", "cpu": [ "arm" ], @@ -1923,9 +1896,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.39.0.tgz", - "integrity": "sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.1.tgz", + "integrity": "sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==", "cpu": [ "arm64" ], @@ -1937,9 +1910,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.39.0.tgz", - "integrity": "sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.1.tgz", + "integrity": "sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==", "cpu": [ "arm64" ], @@ -1951,9 +1924,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.39.0.tgz", - "integrity": "sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.1.tgz", + "integrity": "sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==", "cpu": [ "loong64" ], @@ -1965,9 +1938,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.39.0.tgz", - "integrity": "sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.1.tgz", + "integrity": "sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==", "cpu": [ "ppc64" ], @@ -1979,9 +1952,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.39.0.tgz", - "integrity": "sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.1.tgz", + "integrity": "sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==", "cpu": [ "riscv64" ], @@ -1993,9 +1966,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.39.0.tgz", - "integrity": "sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.1.tgz", + "integrity": "sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==", "cpu": [ "riscv64" ], @@ -2007,9 +1980,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.39.0.tgz", - "integrity": "sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.1.tgz", + "integrity": "sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==", "cpu": [ "s390x" ], @@ -2021,9 +1994,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.39.0.tgz", - "integrity": "sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.1.tgz", + "integrity": "sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==", "cpu": [ "x64" ], @@ -2035,9 +2008,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.39.0.tgz", - "integrity": "sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.1.tgz", + "integrity": "sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==", "cpu": [ "x64" ], @@ -2049,9 +2022,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.39.0.tgz", - "integrity": "sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.1.tgz", + "integrity": "sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==", "cpu": [ "arm64" ], @@ -2063,9 +2036,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.39.0.tgz", - "integrity": "sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.1.tgz", + "integrity": "sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg==", "cpu": [ "ia32" ], @@ -2077,9 +2050,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.39.0.tgz", - "integrity": "sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.1.tgz", + "integrity": "sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw==", "cpu": [ "x64" ], @@ -2108,13 +2081,25 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tanstack/react-table": { - "version": "8.21.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.2.tgz", - "integrity": "sha512-11tNlEDTdIhMJba2RBH+ecJ9l1zgS2kjmexDPAraulc8jeNA4xocSNeyzextT0XJyASil4XsCYlJmf5jEWAtYg==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", "license": "MIT", "dependencies": { - "@tanstack/table-core": "8.21.2" + "@tanstack/table-core": "8.21.3" }, "engines": { "node": ">=12" @@ -2129,12 +2114,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.13.6", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.6.tgz", - "integrity": "sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==", + "version": "3.13.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.9.tgz", + "integrity": "sha512-SPWC8kwG/dWBf7Py7cfheAPOxuvIv4fFQ54PdmYbg7CpXfsKxkucak43Q0qKsxVthhUJQ1A7CIMAIplq4BjVwA==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.13.6" + "@tanstack/virtual-core": "3.13.9" }, "funding": { "type": "github", @@ -2146,9 +2131,9 @@ } }, "node_modules/@tanstack/table-core": { - "version": "8.21.2", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz", - "integrity": "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", "license": "MIT", "engines": { "node": ">=12" @@ -2159,9 +2144,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.13.6", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.6.tgz", - "integrity": "sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==", + "version": "3.13.9", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.9.tgz", + "integrity": "sha512-3jztt0jpaoJO5TARe2WIHC1UQC3VMLAFUW5mmMo0yrkwtDB2AQP0+sh10BVUpWrnvHjSLvzFizydtEGLCJKFoQ==", "license": "MIT", "funding": { "type": "github", @@ -2310,9 +2295,9 @@ } }, "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, "license": "MIT", "dependencies": { @@ -2340,6 +2325,16 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, "node_modules/@types/chart.js": { "version": "2.9.41", "resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.41.tgz", @@ -2356,6 +2351,13 @@ "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/dompurify": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", @@ -2385,23 +2387,23 @@ } }, "node_modules/@types/lodash": { - "version": "4.17.16", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz", - "integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==", + "version": "4.17.17", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.17.tgz", + "integrity": "sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==", "dev": true, "license": "MIT" }, "node_modules/@types/mixpanel-browser": { - "version": "2.54.0", - "resolved": "https://registry.npmjs.org/@types/mixpanel-browser/-/mixpanel-browser-2.54.0.tgz", - "integrity": "sha512-7DMzIH0M9TlpCTMZidaeXris+aMUyAgMMEZtV1xeD6fSQgpCGklUKqyRgidq5hKPKuNEOWBp73549Gusig/xBA==", + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@types/mixpanel-browser/-/mixpanel-browser-2.60.0.tgz", + "integrity": "sha512-70oe8T3KdxHwsSo5aZphALdoqcsIorQBrlisnouIn9Do4dmC2C6/D56978CmSE/BO2QHgb85ojPGa4R8OFvVHA==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { - "version": "20.17.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", - "integrity": "sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==", + "version": "20.17.57", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.57.tgz", + "integrity": "sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2478,17 +2480,18 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", - "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.1.tgz", + "integrity": "sha512-uPZBqSI0YD4lpkIru6M35sIfylLGTyhGHvDZbNLuMA73lMlwJKz5xweH7FajfcCAc2HnINciejA9qTz0dr0M7A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.26.0", + "@babel/core": "^7.26.10", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@rolldown/pluginutils": "1.0.0-beta.9", "@types/babel__core": "^7.20.5", - "react-refresh": "^0.14.2" + "react-refresh": "^0.17.0" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -2498,14 +2501,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.1.tgz", - "integrity": "sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.0.tgz", + "integrity": "sha512-0v4YVbhDKX3SKoy0PHWXpKhj44w+3zZkIoVES9Ex2pq+u6+Bijijbi2ua5kE+h3qT6LBWFTNZSCOEU37H8Y5sA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.0", + "@vitest/utils": "3.2.0", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -2514,13 +2518,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.1.tgz", - "integrity": "sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.0.tgz", + "integrity": "sha512-HFcW0lAMx3eN9vQqis63H0Pscv0QcVMo1Kv8BNysZbxcmHu3ZUYv59DS6BGYiGQ8F5lUkmsfMMlPm4DJFJdf/A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", + "@vitest/spy": "3.2.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -2529,7 +2533,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0" + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -2541,9 +2545,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.1.tgz", - "integrity": "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.0.tgz", + "integrity": "sha512-gUUhaUmPBHFkrqnOokmfMGRBMHhgpICud9nrz/xpNV3/4OXCn35oG+Pl8rYYsKaTNd/FAIrqRHnwpDpmYxCYZw==", "dev": true, "license": "MIT", "dependencies": { @@ -2554,13 +2558,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.1.tgz", - "integrity": "sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.0.tgz", + "integrity": "sha512-bXdmnHxuB7fXJdh+8vvnlwi/m1zvu+I06i1dICVcDQFhyV4iKw2RExC/acavtDn93m/dRuawUObKsrNE1gJacA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.1.1", + "@vitest/utils": "3.2.0", "pathe": "^2.0.3" }, "funding": { @@ -2568,13 +2572,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.1.tgz", - "integrity": "sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.0.tgz", + "integrity": "sha512-z7P/EneBRMe7hdvWhcHoXjhA6at0Q4ipcoZo6SqgxLyQQ8KSMMCmvw1cSt7FHib3ozt0wnRHc37ivuUMbxzG/A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.2.0", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -2583,26 +2587,26 @@ } }, "node_modules/@vitest/spy": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.1.tgz", - "integrity": "sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.0.tgz", + "integrity": "sha512-s3+TkCNUIEOX99S0JwNDfsHRaZDDZZR/n8F0mop0PmsEbQGKZikCGpTGZ6JRiHuONKew3Fb5//EPwCP+pUX9cw==", "dev": true, "license": "MIT", "dependencies": { - "tinyspy": "^3.0.2" + "tinyspy": "^4.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.1.tgz", - "integrity": "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.0.tgz", + "integrity": "sha512-gXXOe7Fj6toCsZKVQouTRLJftJwmvbhH5lKOBR6rlP950zUq9AitTUjnFoXS/CqjBC2aoejAztLPzzuva++XBw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.2.0", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, @@ -2665,12 +2669,12 @@ } }, "node_modules/antd": { - "version": "5.24.9", - "resolved": "https://registry.npmjs.org/antd/-/antd-5.24.9.tgz", - "integrity": "sha512-liB+Y/JwD5/KSKbK1Z1EVAbWcoWYvWJ1s97AbbT+mOdigpJQuWwH7kG8IXNEljI7onvj0DdD43TXhSRLUu9AMA==", + "version": "5.25.4", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.25.4.tgz", + "integrity": "sha512-yXdWqq1NJSZnD1HoPZWnWuQJGVYYnB3h0Ufsz4sbt3T0N9SdJ4G9GPpLMk8Gn9zWtwBekfR4THPVZ9uzAyhBHQ==", "license": "MIT", "dependencies": { - "@ant-design/colors": "^7.2.0", + "@ant-design/colors": "^7.2.1", "@ant-design/cssinjs": "^1.23.0", "@ant-design/cssinjs-utils": "^1.1.3", "@ant-design/fast-color": "^2.0.6", @@ -2685,11 +2689,11 @@ "classnames": "^2.5.1", "copy-to-clipboard": "^3.3.3", "dayjs": "^1.11.11", - "rc-cascader": "~3.33.1", + "rc-cascader": "~3.34.0", "rc-checkbox": "~3.5.0", "rc-collapse": "~3.9.0", "rc-dialog": "~9.6.0", - "rc-drawer": "~7.2.0", + "rc-drawer": "~7.3.0", "rc-dropdown": "~4.2.1", "rc-field-form": "~2.7.0", "rc-image": "~7.12.0", @@ -2705,17 +2709,17 @@ "rc-rate": "~2.13.1", "rc-resize-observer": "^1.4.3", "rc-segmented": "~2.7.0", - "rc-select": "~14.16.6", + "rc-select": "~14.16.8", "rc-slider": "~11.1.8", "rc-steps": "~6.0.1", "rc-switch": "~4.1.0", - "rc-table": "~7.50.4", + "rc-table": "~7.50.5", "rc-tabs": "~15.6.1", "rc-textarea": "~1.10.0", "rc-tooltip": "~6.4.0", "rc-tree": "~5.13.1", "rc-tree-select": "~5.27.0", - "rc-upload": "~4.8.1", + "rc-upload": "~4.9.2", "rc-util": "^5.44.4", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" @@ -2941,9 +2945,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.25.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", + "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", "dev": true, "funding": [ { @@ -2961,10 +2965,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", + "caniuse-lite": "^1.0.30001718", + "electron-to-chromium": "^1.5.160", "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -3035,9 +3039,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001709", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001709.tgz", - "integrity": "sha512-NgL3vUTnDrPCZ3zTahp4fsugQ4dc7EKTSzwQDPEel6DMoMnfH2jhry9n2Zm8onbSR+f/QtKHFOA+iAQu4kbtWA==", + "version": "1.0.30001720", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001720.tgz", + "integrity": "sha512-Ec/2yV2nNPwb4DnTANEV99ZWwm3ZWfdlfkQbWSDDt+PsXEVYwlhPH8tdMaPunYTKKmz7AnHi2oNEi1GcmKCD8g==", "dev": true, "funding": [ { @@ -3075,13 +3079,6 @@ "node": ">=10.0.0" } }, - "node_modules/canvg/node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT", - "optional": true - }, "node_modules/chai": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", @@ -3117,9 +3114,9 @@ } }, "node_modules/chart.js": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.8.tgz", - "integrity": "sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA==", + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz", + "integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==", "license": "MIT", "dependencies": { "@kurkle/color": "^0.3.0" @@ -3255,9 +3252,9 @@ } }, "node_modules/core-js": { - "version": "3.41.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.41.0.tgz", - "integrity": "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==", + "version": "3.42.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.42.0.tgz", + "integrity": "sha512-Sz4PP4ZA+Rq4II21qkNqOEDTDrCvcANId3xpIgB34NDkWc3UduWj2dqEtN9yZIq8Dk3HyPI33x9sqqU5C8sr0g==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3383,9 +3380,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3427,18 +3424,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -3488,9 +3473,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz", - "integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -3518,9 +3503,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.130", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.130.tgz", - "integrity": "sha512-Ou2u7L9j2XLZbhqzyX0jWDj6gA8D3jIfVzt4rikLf3cGBa0VdReuFimBKS9tQJA4+XpeCxj1NoWlfBXzbMa9IA==", + "version": "1.5.162", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.162.tgz", + "integrity": "sha512-hQA+Zb5QQwoSaXJWEAGEw1zhk//O7qDzib05Z4qTqZfNju/FAkrm5ZInp0JbTp4Z18A6bilopdZWEYrFSsfllA==", "dev": true, "license": "ISC" }, @@ -3598,9 +3583,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", - "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, @@ -3632,9 +3617,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz", - "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3645,31 +3630,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.2", - "@esbuild/android-arm": "0.25.2", - "@esbuild/android-arm64": "0.25.2", - "@esbuild/android-x64": "0.25.2", - "@esbuild/darwin-arm64": "0.25.2", - "@esbuild/darwin-x64": "0.25.2", - "@esbuild/freebsd-arm64": "0.25.2", - "@esbuild/freebsd-x64": "0.25.2", - "@esbuild/linux-arm": "0.25.2", - "@esbuild/linux-arm64": "0.25.2", - "@esbuild/linux-ia32": "0.25.2", - "@esbuild/linux-loong64": "0.25.2", - "@esbuild/linux-mips64el": "0.25.2", - "@esbuild/linux-ppc64": "0.25.2", - "@esbuild/linux-riscv64": "0.25.2", - "@esbuild/linux-s390x": "0.25.2", - "@esbuild/linux-x64": "0.25.2", - "@esbuild/netbsd-arm64": "0.25.2", - "@esbuild/netbsd-x64": "0.25.2", - "@esbuild/openbsd-arm64": "0.25.2", - "@esbuild/openbsd-x64": "0.25.2", - "@esbuild/sunos-x64": "0.25.2", - "@esbuild/win32-arm64": "0.25.2", - "@esbuild/win32-ia32": "0.25.2", - "@esbuild/win32-x64": "0.25.2" + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" } }, "node_modules/escalade": { @@ -4048,12 +4033,6 @@ "react-is": "^16.7.0" } }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -4106,9 +4085,9 @@ } }, "node_modules/i18next-browser-languagedetector": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.4.tgz", - "integrity": "sha512-f3frU3pIxD50/Tz20zx9TD9HobKYg47fmAETb117GKGPrhwcSSPJDoCposXlVycVebQ9GQohC3Efbpq7/nnJ5w==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.1.0.tgz", + "integrity": "sha512-mHZxNx1Lq09xt5kCauZ/4bsXOEA2pfpwSoU11/QTJB+pD94iONFwp+ohqi///PwiFvjFOxe1akYCdHyFo1ng5Q==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.2" @@ -4375,257 +4354,6 @@ "html2canvas": "^1.0.0-rc.5" } }, - "node_modules/lightningcss": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.3.tgz", - "integrity": "sha512-GlOJwTIP6TMIlrTFsxTerwC0W6OpQpCGuX1ECRLBUVRh6fpJH3xTqjCjRgQHTb4ZXexH9rtHou1Lf03GKzmhhQ==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-darwin-arm64": "1.29.3", - "lightningcss-darwin-x64": "1.29.3", - "lightningcss-freebsd-x64": "1.29.3", - "lightningcss-linux-arm-gnueabihf": "1.29.3", - "lightningcss-linux-arm64-gnu": "1.29.3", - "lightningcss-linux-arm64-musl": "1.29.3", - "lightningcss-linux-x64-gnu": "1.29.3", - "lightningcss-linux-x64-musl": "1.29.3", - "lightningcss-win32-arm64-msvc": "1.29.3", - "lightningcss-win32-x64-msvc": "1.29.3" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.3.tgz", - "integrity": "sha512-fb7raKO3pXtlNbQbiMeEu8RbBVHnpyqAoxTyTRMEWFQWmscGC2wZxoHzZ+YKAepUuKT9uIW5vL2QbFivTgprZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.3.tgz", - "integrity": "sha512-KF2XZ4ZdmDGGtEYmx5wpzn6u8vg7AdBHaEOvDKu8GOs7xDL/vcU2vMKtTeNe1d4dogkDdi3B9zC77jkatWBwEQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.3.tgz", - "integrity": "sha512-VUWeVf+V1UM54jv9M4wen9vMlIAyT69Krl9XjI8SsRxz4tdNV/7QEPlW6JASev/pYdiynUCW0pwaFquDRYdxMw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.3.tgz", - "integrity": "sha512-UhgZ/XVNfXQVEJrMIWeK1Laj8KbhjbIz7F4znUk7G4zeGw7TRoJxhb66uWrEsonn1+O45w//0i0Fu0wIovYdYg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.3.tgz", - "integrity": "sha512-Pqau7jtgJNmQ/esugfmAT1aCFy/Gxc92FOxI+3n+LbMHBheBnk41xHDhc0HeYlx9G0xP5tK4t0Koy3QGGNqypw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.3.tgz", - "integrity": "sha512-dxakOk66pf7KLS7VRYFO7B8WOJLecE5OPL2YOk52eriFd/yeyxt2Km5H0BjLfElokIaR+qWi33gB8MQLrdAY3A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.3.tgz", - "integrity": "sha512-ySZTNCpbfbK8rqpKJeJR2S0g/8UqqV3QnzcuWvpI60LWxnFN91nxpSSwCbzfOXkzKfar9j5eOuOplf+klKtINg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.3.tgz", - "integrity": "sha512-3pVZhIzW09nzi10usAXfIGTTSTYQ141dk88vGFNCgawIzayiIzZQxEcxVtIkdvlEq2YuFsL9Wcj/h61JHHzuFQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.3.tgz", - "integrity": "sha512-VRnkAvtIkeWuoBJeGOTrZxsNp4HogXtcaaLm8agmbYtLDOhQdpgxW6NjZZjDXbvGF+eOehGulXZ3C1TiwHY4QQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.3.tgz", - "integrity": "sha512-IszwRPu2cPnDQsZpd7/EAr0x2W7jkaWqQ1SwCVIZ/tSbZVXPLt6k8s6FkcyBjViCzvB5CW0We0QbbP7zp2aBjQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4831,9 +4559,9 @@ "license": "MIT" }, "node_modules/mixpanel-browser": { - "version": "2.63.0", - "resolved": "https://registry.npmjs.org/mixpanel-browser/-/mixpanel-browser-2.63.0.tgz", - "integrity": "sha512-h7M0J/LR/5YLWCVuvPaYuzwV7CgV9jkJz0m94uaTDPebWkhNQPEir63rf/ZpBZgntyvYjO1yMZp2pIpwQ1sBMQ==", + "version": "2.65.0", + "resolved": "https://registry.npmjs.org/mixpanel-browser/-/mixpanel-browser-2.65.0.tgz", + "integrity": "sha512-BtrVYqilloAqx3TIhoIpNikHznTocEy/z3QIf6WEiz4PFxrgI6LgSMFIVKqLqGZJ8svrPlHbpp/CJp5wQYUZWw==", "license": "Apache-2.0", "dependencies": { "rrweb": "2.0.0-alpha.18" @@ -5115,9 +4843,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.4", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz", + "integrity": "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==", "funding": [ { "type": "opencollective", @@ -5134,7 +4862,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5281,9 +5009,9 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.6.11", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.11.tgz", - "integrity": "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==", + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.12.tgz", + "integrity": "sha512-OuTQKoqNwV7RnxTPwXWzOFXy6Jc4z8oeRZYGuMpRyG3WbuR3jjXdQFK8qFBMBx8UHWdHrddARz2fgUenild6aw==", "dev": true, "license": "MIT", "engines": { @@ -5395,9 +5123,9 @@ "license": "MIT" }, "node_modules/primereact": { - "version": "10.9.4", - "resolved": "https://registry.npmjs.org/primereact/-/primereact-10.9.4.tgz", - "integrity": "sha512-GMrelh07Wd1cwKjHpay3LCpwP346D43qBVkt8H/anGYC3z7kv5/AP0pizZv+aGQs2Fg5ufTTf+SI7IKWmyzgGg==", + "version": "10.9.6", + "resolved": "https://registry.npmjs.org/primereact/-/primereact-10.9.6.tgz", + "integrity": "sha512-0Jjz/KzfUURSHaPTXJwjL2Dc7CDPnbO17MivyJz7T5smGAMLY5d+IqpQhV61R22G/rDmhMh3+32LCNva2M8fRw==", "license": "MIT", "dependencies": { "@types/react-transition-group": "^4.4.1", @@ -5428,12 +5156,6 @@ "react-is": "^16.13.1" } }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -5495,16 +5217,10 @@ "shallowequal": "^1.1.0" } }, - "node_modules/rc-animate/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/rc-cascader": { - "version": "3.33.1", - "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.33.1.tgz", - "integrity": "sha512-Kyl4EJ7ZfCBuidmZVieegcbFw0RcU5bHHSbtEdmuLYd0fYHCAiYKZ6zon7fWAVyC6rWWOOib0XKdTSf7ElC9rg==", + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", @@ -5567,9 +5283,9 @@ } }, "node_modules/rc-drawer": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.2.0.tgz", - "integrity": "sha512-9lOQ7kBekEJRdEpScHvtmEtXnAsy+NGDXiRWc2ZVC7QXAazNVbeT4EraQKYwCME8BJLa8Bxqxvs5swwyOepRwg==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", @@ -5650,12 +5366,6 @@ "shallowequal": "^1.1.0" } }, - "node_modules/rc-form/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/rc-image": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", @@ -5913,9 +5623,9 @@ } }, "node_modules/rc-select": { - "version": "14.16.6", - "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.6.tgz", - "integrity": "sha512-YPMtRPqfZWOm2XGTbx5/YVr1HT0vn//8QS77At0Gjb3Lv+Lbut0IORJPKLWu1hQ3u4GsA0SrDzs7nI8JG7Zmyg==", + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.1", @@ -5986,9 +5696,9 @@ } }, "node_modules/rc-table": { - "version": "7.50.4", - "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.50.4.tgz", - "integrity": "sha512-Y+YuncnQqoS5e7yHvfvlv8BmCvwDYDX/2VixTBEhkMDk9itS9aBINp4nhzXFKiBP/frG4w0pS9d9Rgisl0T1Bw==", + "version": "7.50.5", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.50.5.tgz", + "integrity": "sha512-FDZu8aolhSYd3v9KOc3lZOVAU77wmRRu44R0Wfb8Oj1dXRUsloFaXMSl6f7yuWZUxArJTli7k8TEOX2mvhDl4A==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.1", @@ -6099,9 +5809,9 @@ } }, "node_modules/rc-upload": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.8.1.tgz", - "integrity": "sha512-toEAhwl4hjLAI1u8/CgKWt30BR06ulPa4iGQSMvSXoHzO88gPCslxqV/mnn4gJU7PDoltGIC9Eh+wkeudqgHyw==", + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.9.2.tgz", + "integrity": "sha512-nHx+9rbd1FKMiMRYsqQ3NkXUv7COHPBo3X1Obwq9SWS6/diF/A0aJ5OHubvwUAIDs+4RMleljV0pcrNUc823GQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", @@ -6134,9 +5844,9 @@ "license": "MIT" }, "node_modules/rc-virtual-list": { - "version": "3.18.5", - "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.18.5.tgz", - "integrity": "sha512-1FuxVSxhzTj3y8k5xMPbhXCB0t2TOiI3Tq+qE2Bu+GGV7f+ECVuQl4OUg6lZ2qT5fordTW7CBpr9czdzXCI7Pg==", + "version": "3.18.6", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.18.6.tgz", + "integrity": "sha512-TQ5SsutL3McvWmmxqQtMIbfeoE3dGjJrRSfKekgby7WQMpPIFvv4ghytp5Z0s3D8Nik9i9YNOCqHBfk86AwgAA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", @@ -6198,9 +5908,9 @@ } }, "node_modules/react-i18next": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.1.tgz", - "integrity": "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw==", + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.2.tgz", + "integrity": "sha512-ePODyXgmZQAOYTbZXQn5rRsSBu3Gszo69jxW6aKmlSgxKAI1fOhDwSu6bT4EKHciWPKQ7v7lPrjeiadR6Gi+1A==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.0", @@ -6208,7 +5918,8 @@ }, "peerDependencies": { "i18next": ">= 23.2.3", - "react": ">= 16.8.0" + "react": ">= 16.8.0", + "typescript": "^5" }, "peerDependenciesMeta": { "react-dom": { @@ -6216,9 +5927,18 @@ }, "react-native": { "optional": true + }, + "typescript": { + "optional": true } } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-lifecycles-compat": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", @@ -6263,9 +5983,9 @@ } }, "node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", "dev": true, "license": "MIT", "engines": { @@ -6291,9 +6011,9 @@ } }, "node_modules/react-router": { - "version": "6.30.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz", - "integrity": "sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==", + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", "license": "MIT", "dependencies": { "@remix-run/router": "1.23.0" @@ -6306,13 +6026,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.0.tgz", - "integrity": "sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==", + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", "license": "MIT", "dependencies": { "@remix-run/router": "1.23.0", - "react-router": "6.30.0" + "react-router": "6.30.1" }, "engines": { "node": ">=14.0.0" @@ -6426,10 +6146,11 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true }, "node_modules/reselect": { "version": "5.1.1", @@ -6494,9 +6215,9 @@ } }, "node_modules/rollup": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.39.0.tgz", - "integrity": "sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.1.tgz", + "integrity": "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==", "dev": true, "license": "MIT", "dependencies": { @@ -6510,26 +6231,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.39.0", - "@rollup/rollup-android-arm64": "4.39.0", - "@rollup/rollup-darwin-arm64": "4.39.0", - "@rollup/rollup-darwin-x64": "4.39.0", - "@rollup/rollup-freebsd-arm64": "4.39.0", - "@rollup/rollup-freebsd-x64": "4.39.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.39.0", - "@rollup/rollup-linux-arm-musleabihf": "4.39.0", - "@rollup/rollup-linux-arm64-gnu": "4.39.0", - "@rollup/rollup-linux-arm64-musl": "4.39.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.39.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.39.0", - "@rollup/rollup-linux-riscv64-gnu": "4.39.0", - "@rollup/rollup-linux-riscv64-musl": "4.39.0", - "@rollup/rollup-linux-s390x-gnu": "4.39.0", - "@rollup/rollup-linux-x64-gnu": "4.39.0", - "@rollup/rollup-linux-x64-musl": "4.39.0", - "@rollup/rollup-win32-arm64-msvc": "4.39.0", - "@rollup/rollup-win32-ia32-msvc": "4.39.0", - "@rollup/rollup-win32-x64-msvc": "4.39.0", + "@rollup/rollup-android-arm-eabi": "4.41.1", + "@rollup/rollup-android-arm64": "4.41.1", + "@rollup/rollup-darwin-arm64": "4.41.1", + "@rollup/rollup-darwin-x64": "4.41.1", + "@rollup/rollup-freebsd-arm64": "4.41.1", + "@rollup/rollup-freebsd-x64": "4.41.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.41.1", + "@rollup/rollup-linux-arm-musleabihf": "4.41.1", + "@rollup/rollup-linux-arm64-gnu": "4.41.1", + "@rollup/rollup-linux-arm64-musl": "4.41.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.41.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.41.1", + "@rollup/rollup-linux-riscv64-gnu": "4.41.1", + "@rollup/rollup-linux-riscv64-musl": "4.41.1", + "@rollup/rollup-linux-s390x-gnu": "4.41.1", + "@rollup/rollup-linux-x64-gnu": "4.41.1", + "@rollup/rollup-linux-x64-musl": "4.41.1", + "@rollup/rollup-win32-arm64-msvc": "4.41.1", + "@rollup/rollup-win32-ia32-msvc": "4.41.1", + "@rollup/rollup-win32-x64-msvc": "4.41.1", "fsevents": "~2.3.2" } }, @@ -6802,9 +6523,9 @@ } }, "node_modules/std-env": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.1.tgz", - "integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", "dev": true, "license": "MIT" }, @@ -7040,14 +6761,14 @@ } }, "node_modules/terser": { - "version": "5.39.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", - "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", + "version": "5.40.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.40.0.tgz", + "integrity": "sha512-cfeKl/jjwSR5ar7d0FGmave9hFGJT8obyo0z+CrQOylLDbk7X81nPU6vq9VORa5jU30SkDnT2FXjLbR8HLP+xA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", + "acorn": "^8.14.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -7127,9 +6848,9 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7144,9 +6865,9 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz", + "integrity": "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7172,15 +6893,15 @@ } }, "node_modules/tinymce": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-7.7.2.tgz", - "integrity": "sha512-GX7Jd0ac9ph3QM2yei4uOoxytKX096CyG6VkkgQNikY39T6cDldoNgaqzHHlcm62WtdBMCd7Ch+PYaRnQo+NLA==", + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-7.9.1.tgz", + "integrity": "sha512-zaOHwmiP1EqTeLRXAvVriDb00JYnfEjWGPdKEuac7MiZJ5aiDMZ4Unc98Gmajn+PBljOmO1GKV6G0KwWn3+k8A==", "license": "GPL-2.0-or-later" }, "node_modules/tinypool": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", - "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.0.tgz", + "integrity": "sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==", "dev": true, "license": "MIT", "engines": { @@ -7198,9 +6919,9 @@ } }, "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", + "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", "dev": true, "license": "MIT", "engines": { @@ -7240,9 +6961,9 @@ "license": "Apache-2.0" }, "node_modules/tsconfck": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.5.tgz", - "integrity": "sha512-CLDfGgUp7XPswWnezWwsCRxNmgQjhYq3VXHM0/XIRxhVrKw0M1if9agzryh1QS3nxjCROvV+xWxoJO1YctzzWg==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", "dev": true, "license": "MIT", "bin": { @@ -7270,7 +6991,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7419,17 +7140,17 @@ } }, "node_modules/vite-node": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.1.tgz", - "integrity": "sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.0.tgz", + "integrity": "sha512-8Fc5Ko5Y4URIJkmMF/iFP1C0/OJyY+VGVe9Nw6WAdZyw4bTO+eVg9mwxWkQp/y8NnAoQY3o9KAvE1ZdA2v+Vmg==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.4.0", - "es-module-lexer": "^1.6.0", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0" + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" @@ -7462,9 +7183,9 @@ } }, "node_modules/vite/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz", + "integrity": "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7490,31 +7211,34 @@ } }, "node_modules/vitest": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.1.tgz", - "integrity": "sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.0.tgz", + "integrity": "sha512-P7Nvwuli8WBNmeMHHek7PnGW4oAZl9za1fddfRVidZar8wDZRi7hpznLKQePQ8JPLwSBEYDK11g+++j7uFJV8Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.1.1", - "@vitest/mocker": "3.1.1", - "@vitest/pretty-format": "^3.1.1", - "@vitest/runner": "3.1.1", - "@vitest/snapshot": "3.1.1", - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.0", + "@vitest/mocker": "3.2.0", + "@vitest/pretty-format": "^3.2.0", + "@vitest/runner": "3.2.0", + "@vitest/snapshot": "3.2.0", + "@vitest/spy": "3.2.0", + "@vitest/utils": "3.2.0", "chai": "^5.2.0", - "debug": "^4.4.0", - "expect-type": "^1.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", - "std-env": "^3.8.1", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", - "tinypool": "^1.0.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.0", "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.1.1", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -7530,8 +7254,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.1.1", - "@vitest/ui": "3.1.1", + "@vitest/browser": "3.2.0", + "@vitest/ui": "3.2.0", "happy-dom": "*", "jsdom": "*" }, @@ -7559,6 +7283,19 @@ } } }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -7738,6 +7475,16 @@ } } }, + "node_modules/wx-react-gantt": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/wx-react-gantt/-/wx-react-gantt-1.3.1.tgz", + "integrity": "sha512-Ua1hrMXfXENjhTVFBDf9D2mTXsw8BEHUDUUgnjDyQ4iXDEd5ueZGoUiCpBSX85XCDC8zIlm2f0KfuVSYNLuMRA==", + "license": "GPLv3", + "peerDependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + } + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", @@ -7754,16 +7501,16 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", - "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" } } } diff --git a/worklenz-frontend/package.json b/worklenz-frontend/package.json index 562d1b00d..79f533b6e 100644 --- a/worklenz-frontend/package.json +++ b/worklenz-frontend/package.json @@ -2,6 +2,7 @@ "name": "worklenz", "version": "1.0.0", "private": true, + "type": "module", "scripts": { "start": "vite", "prebuild": "node scripts/copy-tinymce.js", @@ -16,8 +17,8 @@ "@ant-design/icons": "^5.4.0", "@ant-design/pro-components": "^2.7.19", "@dnd-kit/core": "^6.3.1", - "@dnd-kit/modifiers": "^9.0.0", - "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/modifiers": "^6.0.1", + "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@paddle/paddle-js": "^1.3.3", @@ -52,7 +53,8 @@ "react-window": "^1.8.11", "socket.io-client": "^4.8.1", "tinymce": "^7.7.2", - "web-vitals": "^4.2.4" + "web-vitals": "^4.2.4", + "wx-react-gantt": "^1.3.1" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.3", @@ -77,6 +79,12 @@ "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.0.5" }, + "resolutions": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/utilities": "^3.2.2", + "@dnd-kit/sortable": "^7.0.2", + "@dnd-kit/modifiers": "^6.0.1" + }, "eslintConfig": { "extends": [ "react-app", diff --git a/worklenz-frontend/postcss.config.js b/worklenz-frontend/postcss.config.js index 12a703d90..2aa7205d4 100644 --- a/worklenz-frontend/postcss.config.js +++ b/worklenz-frontend/postcss.config.js @@ -1,4 +1,4 @@ -module.exports = { +export default { plugins: { tailwindcss: {}, autoprefixer: {}, diff --git a/worklenz-frontend/public/finance-mock-data/finance-task-priority.json b/worklenz-frontend/public/finance-mock-data/finance-task-priority.json new file mode 100644 index 000000000..556c06a8d --- /dev/null +++ b/worklenz-frontend/public/finance-mock-data/finance-task-priority.json @@ -0,0 +1,163 @@ +[ + { + "id": "c2669c5f-a019-445b-b703-b941bbefdab7", + "type": "low", + "name": "Low", + "color_code": "#c2e4d0", + "color_code_dark": "#46d980", + "tasks": [ + { + "id": "4be5ef5c-1234-4247-b159-6d8df2b37d04", + "task": "Testing and QA", + "isBillable": false, + "hours": 180, + "cost": 18000, + "fixedCost": 2500, + "totalBudget": 20000, + "totalActual": 21000, + "variance": -1000, + "members": [ + { + "memberId": "6", + "name": "Eve Adams", + "jobId": "J006", + "jobRole": "QA Engineer", + "hourlyRate": 100 + } + ] + }, + { + "id": "6be5ef5c-1234-4247-b159-6d8df2b37d06", + "task": "Project Documentation", + "isBillable": false, + "hours": 100, + "cost": 10000, + "fixedCost": 1000, + "totalBudget": 12000, + "totalActual": 12500, + "variance": -500, + "members": [ + { + "memberId": "8", + "name": "Grace Lee", + "jobId": "J008", + "jobRole": "Technical Writer", + "hourlyRate": 100 + } + ] + } + ] + }, + { + "id": "d3f9c5f1-b019-445b-b703-b941bbefdab8", + "type": "medium", + "name": "Medium", + "color_code": "#f9e3b1", + "color_code_dark": "#ffc227", + "tasks": [ + { + "id": "1be5ef5c-1234-4247-b159-6d8df2b37d01", + "task": "UI Design", + "isBillable": true, + "hours": 120, + "cost": 12000, + "fixedCost": 1500, + "totalBudget": 14000, + "totalActual": 13500, + "variance": 500, + "members": [ + { + "memberId": "1", + "name": "John Doe", + "jobId": "J001", + "jobRole": "UI/UX Designer", + "hourlyRate": 100 + }, + { + "memberId": "2", + "name": "Jane Smith", + "jobId": "J002", + "jobRole": "Frontend Developer", + "hourlyRate": 120 + } + ] + }, + { + "id": "2be5ef5c-1234-4247-b159-6d8df2b37d02", + "task": "API Integration", + "isBillable": true, + "hours": 200, + "cost": 20000, + "fixedCost": 3000, + "totalBudget": 25000, + "totalActual": 26000, + "variance": -1000, + "members": [ + { + "memberId": "3", + "name": "Alice Johnson", + "jobId": "J003", + "jobRole": "Backend Developer", + "hourlyRate": 100 + } + ] + } + ] + }, + { + "id": "e3f9c5f1-b019-445b-b703-b941bbefdab9", + "type": "high", + "name": "High", + "color_code": "#f6bfc0", + "color_code_dark": "#ff4141", + "tasks": [ + { + "id": "5be5ef5c-1234-4247-b159-6d8df2b37d05", + "task": "Database Migration", + "isBillable": true, + "hours": 250, + "cost": 37500, + "fixedCost": 4000, + "totalBudget": 42000, + "totalActual": 41000, + "variance": 1000, + "members": [ + { + "memberId": "7", + "name": "Frank Harris", + "jobId": "J007", + "jobRole": "Database Administrator", + "hourlyRate": 150 + } + ] + }, + { + "id": "3be5ef5c-1234-4247-b159-6d8df2b37d03", + "task": "Performance Optimization", + "isBillable": true, + "hours": 300, + "cost": 45000, + "fixedCost": 5000, + "totalBudget": 50000, + "totalActual": 47000, + "variance": 3000, + "members": [ + { + "memberId": "4", + "name": "Bob Brown", + "jobId": "J004", + "jobRole": "Performance Engineer", + "hourlyRate": 150 + }, + { + "memberId": "5", + "name": "Charlie Davis", + "jobId": "J005", + "jobRole": "Full Stack Developer", + "hourlyRate": 130 + } + ] + } + ] + } +] diff --git a/worklenz-frontend/public/finance-mock-data/finance-task-status.json b/worklenz-frontend/public/finance-mock-data/finance-task-status.json new file mode 100644 index 000000000..888a66c05 --- /dev/null +++ b/worklenz-frontend/public/finance-mock-data/finance-task-status.json @@ -0,0 +1,163 @@ +[ + { + "id": "c2669c5f-a019-445b-b703-b941bbefdab7", + "type": "todo", + "name": "To Do", + "color_code": "#d8d7d8", + "color_code_dark": "#989898", + "tasks": [ + { + "id": "1be5ef5c-1234-4247-b159-6d8df2b37d01", + "task": "UI Design", + "isBillable": true, + "hours": 120, + "cost": 12000, + "fixedCost": 1500, + "totalBudget": 14000, + "totalActual": 13500, + "variance": 500, + "members": [ + { + "memberId": "1", + "name": "John Doe", + "jobId": "J001", + "jobRole": "UI/UX Designer", + "hourlyRate": 100 + }, + { + "memberId": "2", + "name": "Jane Smith", + "jobId": "J002", + "jobRole": "Frontend Developer", + "hourlyRate": 120 + } + ] + }, + { + "id": "2be5ef5c-1234-4247-b159-6d8df2b37d02", + "task": "API Integration", + "isBillable": true, + "hours": 200, + "cost": 20000, + "fixedCost": 3000, + "totalBudget": 25000, + "totalActual": 26000, + "variance": -1000, + "members": [ + { + "memberId": "3", + "name": "Alice Johnson", + "jobId": "J003", + "jobRole": "Backend Developer", + "hourlyRate": 100 + } + ] + } + ] + }, + { + "id": "d3f9c5f1-b019-445b-b703-b941bbefdab8", + "type": "doing", + "name": "In Progress", + "color_code": "#c0d5f6", + "color_code_dark": "#4190ff", + "tasks": [ + { + "id": "3be5ef5c-1234-4247-b159-6d8df2b37d03", + "task": "Performance Optimization", + "isBillable": true, + "hours": 300, + "cost": 45000, + "fixedCost": 5000, + "totalBudget": 50000, + "totalActual": 47000, + "variance": 3000, + "members": [ + { + "memberId": "4", + "name": "Bob Brown", + "jobId": "J004", + "jobRole": "Performance Engineer", + "hourlyRate": 150 + }, + { + "memberId": "5", + "name": "Charlie Davis", + "jobId": "J005", + "jobRole": "Full Stack Developer", + "hourlyRate": 130 + } + ] + }, + { + "id": "4be5ef5c-1234-4247-b159-6d8df2b37d04", + "task": "Testing and QA", + "isBillable": false, + "hours": 180, + "cost": 18000, + "fixedCost": 2500, + "totalBudget": 20000, + "totalActual": 21000, + "variance": -1000, + "members": [ + { + "memberId": "6", + "name": "Eve Adams", + "jobId": "J006", + "jobRole": "QA Engineer", + "hourlyRate": 100 + } + ] + } + ] + }, + { + "id": "e3f9c5f1-b019-445b-b703-b941bbefdab9", + "type": "done", + "name": "Done", + "color_code": "#c2e4d0", + "color_code_dark": "#46d980", + "tasks": [ + { + "id": "5be5ef5c-1234-4247-b159-6d8df2b37d05", + "task": "Database Migration", + "isBillable": true, + "hours": 250, + "cost": 37500, + "fixedCost": 4000, + "totalBudget": 42000, + "totalActual": 41000, + "variance": 1000, + "members": [ + { + "memberId": "7", + "name": "Frank Harris", + "jobId": "J007", + "jobRole": "Database Administrator", + "hourlyRate": 150 + } + ] + }, + { + "id": "6be5ef5c-1234-4247-b159-6d8df2b37d06", + "task": "Project Documentation", + "isBillable": false, + "hours": 100, + "cost": 10000, + "fixedCost": 1000, + "totalBudget": 12000, + "totalActual": 12500, + "variance": -500, + "members": [ + { + "memberId": "8", + "name": "Grace Lee", + "jobId": "J008", + "jobRole": "Technical Writer", + "hourlyRate": 100 + } + ] + } + ] + } +] diff --git a/worklenz-frontend/public/finance-mock-data/ratecards-data.json b/worklenz-frontend/public/finance-mock-data/ratecards-data.json new file mode 100644 index 000000000..bd2c96243 --- /dev/null +++ b/worklenz-frontend/public/finance-mock-data/ratecards-data.json @@ -0,0 +1,51 @@ +[ + { + "ratecardId": "RC001", + "ratecardName": "Rate Card 1", + "jobRolesList": [ + { + "jobId": "J001", + "jobTitle": "Project Manager", + "ratePerHour": 100 + }, + { + "jobId": "J002", + "jobTitle": "Senior Software Engineer", + "ratePerHour": 120 + }, + { + "jobId": "J003", + "jobTitle": "Junior Software Engineer", + "ratePerHour": 80 + }, + { + "jobId": "J004", + "jobTitle": "UI/UX Designer", + "ratePerHour": 50 + } + ], + "createdDate": "2024-12-01T00:00:00.000Z" + }, + { + "ratecardId": "RC002", + "ratecardName": "Rate Card 2", + "jobRolesList": [ + { + "jobId": "J001", + "jobTitle": "Project Manager", + "ratePerHour": 80 + }, + { + "jobId": "J002", + "jobTitle": "Senior Software Engineer", + "ratePerHour": 100 + }, + { + "jobId": "J003", + "jobTitle": "Junior Software Engineer", + "ratePerHour": 60 + } + ], + "createdDate": "2024-12-15T00:00:00.000Z" + } +] diff --git a/worklenz-frontend/public/locales/en/admin-center/overview.json b/worklenz-frontend/public/locales/en/admin-center/overview.json index efc42855f..663c08e58 100644 --- a/worklenz-frontend/public/locales/en/admin-center/overview.json +++ b/worklenz-frontend/public/locales/en/admin-center/overview.json @@ -4,5 +4,19 @@ "owner": "Organization Owner", "admins": "Organization Admins", "contactNumber": "Add Contact Number", - "edit": "Edit" + "edit": "Edit", + "organizationWorkingDaysAndHours": "Organization Working Days & Hours", + "workingDays": "Working Days", + "workingHours": "Working Hours", + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday", + "hours": "hours", + "saveButton": "Save", + "saved": "Saved successfully!", + "errorSaving": "Error saving settings." } diff --git a/worklenz-frontend/public/locales/en/project-view-finance.json b/worklenz-frontend/public/locales/en/project-view-finance.json new file mode 100644 index 000000000..de496e363 --- /dev/null +++ b/worklenz-frontend/public/locales/en/project-view-finance.json @@ -0,0 +1,46 @@ +{ + "financeText": "Finance", + "ratecardSingularText": "Rate Card", + "groupByText": "Group by", + "statusText": "Status", + "phaseText": "Phase", + "priorityText": "Priority", + "exportButton": "Export", + "currencyText": "Currency", + "importButton": "Import", + "filterText": "Filter", + "billableOnlyText": "Billable Only", + "nonBillableOnlyText": "Non-Billable Only", + "allTasksText": "All Tasks", + + "taskColumn": "Task", + "membersColumn": "Members", + "hoursColumn": "Estimated Hours", + "totalTimeLoggedColumn": "Total Time Logged", + "costColumn": "Actual Cost", + "estimatedCostColumn": "Estimated Cost", + "fixedCostColumn": "Fixed Cost", + "totalBudgetedCostColumn": "Total Budgeted Cost", + "totalActualCostColumn": "Total Actual Cost", + "varianceColumn": "Variance", + "totalText": "Total", + "noTasksFound": "No tasks found", + + "addRoleButton": "+ Add Role", + "ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.", + "saveButton": "Save", + + "jobTitleColumn": "Job Title", + "ratePerHourColumn": "Rate per hour", + "ratecardPluralText": "Rate Cards", + "labourHoursColumn": "Labour Hours", + "actions": "Actions", + "selectJobTitle": "Select Job Title", + "ratecardsPluralText": "Rate Card Templates", + "deleteConfirm": "Are you sure ?", + "yes": "Yes", + "no": "No", + "alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one." + + } + \ No newline at end of file diff --git a/worklenz-frontend/public/locales/en/reporting-members.json b/worklenz-frontend/public/locales/en/reporting-members.json index a8035dcd5..637decfc5 100644 --- a/worklenz-frontend/public/locales/en/reporting-members.json +++ b/worklenz-frontend/public/locales/en/reporting-members.json @@ -31,5 +31,10 @@ "todoText": "To Do", "doingText": "Doing", - "doneText": "Done" + "doneText": "Done", + + "timeLogsColumn": "Time Logs", + "timeLogsColumnTooltip": "Shows the proportion of billable vs non-billable time", + "billable": "Billable", + "nonBillable": "Non-Billable" } diff --git a/worklenz-frontend/public/locales/en/settings/ratecard-settings.json b/worklenz-frontend/public/locales/en/settings/ratecard-settings.json new file mode 100644 index 000000000..3607b7ee6 --- /dev/null +++ b/worklenz-frontend/public/locales/en/settings/ratecard-settings.json @@ -0,0 +1,50 @@ +{ + "nameColumn": "Name", + "createdColumn": "Created", + "noProjectsAvailable": "No projects available", + "deleteConfirmationTitle": "Are you sure you want to delete this rate card?", + "deleteConfirmationOk": "Yes, delete", + "deleteConfirmationCancel": "Cancel", + "searchPlaceholder": "Search rate cards by name", + "createRatecard": "Create Rate Card", + "editTooltip": "Edit rate card", + "deleteTooltip": "Delete rate card", + "fetchError": "Failed to fetch rate cards", + "createError": "Failed to create rate card", + "deleteSuccess": "Rate card deleted successfully", + "deleteError": "Failed to delete rate card", + + "jobTitleColumn": "Job title", + "ratePerHourColumn": "Rate per hour", + "saveButton": "Save", + "addRoleButton": "Add Role", + "createRatecardSuccessMessage": "Rate card created successfully", + "createRatecardErrorMessage": "Failed to create rate card", + "updateRatecardSuccessMessage": "Rate card updated successfully", + "updateRatecardErrorMessage": "Failed to update rate card", + "currency": "Currency", + "actionsColumn": "Actions", + "addAllButton": "Add All", + "removeAllButton": "Remove All", + "selectJobTitle": "Select job title", + "unsavedChangesTitle": "You have unsaved changes", + "unsavedChangesMessage": "Do you want to save your changes before leaving?", + "unsavedChangesSave": "Save", + "unsavedChangesDiscard": "Discard", + "ratecardNameRequired": "Rate card name is required", + "ratecardNamePlaceholder": "Enter rate card name", + "noRatecardsFound": "No rate cards found", + "loadingRateCards": "Loading rate cards...", + "noJobTitlesAvailable": "No job titles available", + "noRolesAdded": "No roles added yet", + "createFirstJobTitle": "Create First Job Title", + "jobRolesTitle": "Job Roles", + "noJobTitlesMessage": "Please create job titles first in the Job Titles settings before adding roles to rate cards.", + "createNewJobTitle": "Create New Job Title", + "jobTitleNamePlaceholder": "Enter job title name", + "jobTitleNameRequired": "Job title name is required", + "jobTitleCreatedSuccess": "Job title created successfully", + "jobTitleCreateError": "Failed to create job title", + "createButton": "Create", + "cancelButton": "Cancel" +} 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 06575ee1a..12a69bb10 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json @@ -23,6 +23,7 @@ "show-start-date": "Show Start Date", "hours": "Hours", "minutes": "Minutes", + "time-estimation-disabled-tooltip": "Time estimation is disabled because this task has {{count}} subtasks. The estimation shown is the sum of all subtasks.", "progressValue": "Progress Value", "progressValueTooltip": "Set the progress percentage (0-100%)", "progressValueRequired": "Please enter a progress value", @@ -79,7 +80,21 @@ "addTimeLog": "Add new time log", "totalLogged": "Total Logged", "exportToExcel": "Export to Excel", - "noTimeLogsFound": "No time logs found" + "noTimeLogsFound": "No time logs found", + "timerDisabledTooltip": "Timer is disabled because this task has {{count}} subtasks. Time should be logged on individual subtasks.", + "timeLogDisabledTooltip": "Time logging is disabled because this task has {{count}} subtasks. Time should be logged on individual subtasks.", + "date": "Date", + "startTime": "Start Time", + "endTime": "End Time", + "workDescription": "Work Description", + "requiredFields": "Please fill in all required fields", + "dateRequired": "Please select a date", + "startTimeRequired": "Please select start time", + "endTimeRequired": "Please select end time", + "workDescriptionPlaceholder": "Add a description", + "cancel": "Cancel", + "logTime": "Log time", + "updateTime": "Update time" }, "taskActivityLogTab": { "title": "Activity Log" diff --git a/worklenz-frontend/public/locales/en/time-report.json b/worklenz-frontend/public/locales/en/time-report.json index b5da8dd25..4a418b6d9 100644 --- a/worklenz-frontend/public/locales/en/time-report.json +++ b/worklenz-frontend/public/locales/en/time-report.json @@ -5,6 +5,7 @@ "searchByName": "Search by name", "selectAll": "Select All", + "clearAll": "Clear All", "teams": "Teams", "searchByProject": "Search by project name", @@ -15,6 +16,8 @@ "billable": "Billable", "nonBillable": "Non Billable", + "filterByBillableStatus": "Filter by Billable Status", + "allBillableTypes": "All Billable Types", "total": "Total", @@ -40,5 +43,25 @@ "noCategory": "No Category", "noProjects": "No projects found", "noTeams": "No teams found", - "noData": "No data found" + "noData": "No data found", + "members": "Members", + "searchByMember": "Search by member", + "utilization": "Utilization", + + "totalTimeLogged": "Total Time Logged", + "expectedCapacity": "Expected Capacity", + "teamUtilization": "Team Utilization", + "variance": "Variance", + "acrossAllTeamMembers": "Across all team members", + "basedOnWorkingSchedule": "Based on working schedule", + "optimal": "Optimal", + "underUtilized": "Under-utilized", + "overUtilized": "Over-utilized", + "overCapacity": "Over capacity", + "underCapacity": "Under capacity", + "considerWorkloadRedistribution": "Consider workload redistribution", + "capacityAvailableForNewProjects": "Capacity available for new projects", + "targetRange": "Target: 90-110%", + "overtimeWork": "Overtime Work", + "reviewWorkLifeBalance": "Review work-life balance policies" } diff --git a/worklenz-frontend/public/locales/es/admin-center/overview.json b/worklenz-frontend/public/locales/es/admin-center/overview.json index f88dbdf6a..c15e15e02 100644 --- a/worklenz-frontend/public/locales/es/admin-center/overview.json +++ b/worklenz-frontend/public/locales/es/admin-center/overview.json @@ -4,5 +4,19 @@ "owner": "Propietario de la Organización", "admins": "Administradores de la Organización", "contactNumber": "Agregar Número de Contacto", - "edit": "Editar" + "edit": "Editar", + "organizationWorkingDaysAndHours": "Días y Horas Laborales de la Organización", + "workingDays": "Días Laborales", + "workingHours": "Horas Laborales", + "monday": "Lunes", + "tuesday": "Martes", + "wednesday": "Miércoles", + "thursday": "Jueves", + "friday": "Viernes", + "saturday": "Sábado", + "sunday": "Domingo", + "hours": "horas", + "saveButton": "Guardar", + "saved": "¡Guardado exitosamente!", + "errorSaving": "Error al guardar la configuración." } diff --git a/worklenz-frontend/public/locales/es/project-view-finance.json b/worklenz-frontend/public/locales/es/project-view-finance.json new file mode 100644 index 000000000..ad3ed6622 --- /dev/null +++ b/worklenz-frontend/public/locales/es/project-view-finance.json @@ -0,0 +1,45 @@ +{ + "financeText": "Finanzas", + "ratecardSingularText": "Tarifa", + "groupByText": "Agrupar por", + "statusText": "Estado", + "phaseText": "Fase", + "priorityText": "Prioridad", + "exportButton": "Exportar", + "currencyText": "Moneda", + "importButton": "Importar", + "filterText": "Filtro", + "billableOnlyText": "Solo Facturable", + "nonBillableOnlyText": "Solo No Facturable", + "allTasksText": "Todas las Tareas", + + "taskColumn": "Tarea", + "membersColumn": "Miembros", + "hoursColumn": "Horas Estimadas", + "totalTimeLoggedColumn": "Tiempo Total Registrado", + "costColumn": "Costo Real", + "estimatedCostColumn": "Costo Estimado", + "fixedCostColumn": "Costo Fijo", + "totalBudgetedCostColumn": "Costo Total Presupuestado", + "totalActualCostColumn": "Costo Real Total", + "varianceColumn": "Varianza", + "totalText": "Total", + "noTasksFound": "No se encontraron tareas", + + "addRoleButton": "+ Agregar Rol", + "ratecardImportantNotice": "* Esta tarifa se genera en base a los títulos de trabajo y tarifas estándar de la empresa. Sin embargo, tienes la flexibilidad de modificarla según el proyecto. Estos cambios no afectarán los títulos de trabajo y tarifas estándar de la organización.", + "saveButton": "Guardar", + + "jobTitleColumn": "Título del Trabajo", + "ratePerHourColumn": "Tarifa por hora", + "ratecardPluralText": "Tarifas", + "labourHoursColumn": "Horas de Trabajo", + "actions": "Acciones", + "selectJobTitle": "Seleccionar Título del Trabajo", + "ratecardsPluralText": "Plantillas de Tarifas", + "deleteConfirm": "¿Estás seguro?", + "yes": "Sí", + "no": "No", + "alreadyImportedRateCardMessage": "Ya se ha importado una tarifa. Borra todas las tarifas importadas para agregar una nueva." +} + \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/reporting-members.json b/worklenz-frontend/public/locales/es/reporting-members.json index d87cafb8a..73887b7e4 100644 --- a/worklenz-frontend/public/locales/es/reporting-members.json +++ b/worklenz-frontend/public/locales/es/reporting-members.json @@ -31,5 +31,10 @@ "todoText": "Por Hacer", "doingText": "Haciendo", - "doneText": "Hecho" + "doneText": "Hecho", + + "timeLogsColumn": "Registros de Tiempo", + "timeLogsColumnTooltip": "Muestra la proporción de tiempo facturable vs no facturable", + "billable": "Facturable", + "nonBillable": "No Facturable" } diff --git a/worklenz-frontend/public/locales/es/settings/ratecard-settings.json b/worklenz-frontend/public/locales/es/settings/ratecard-settings.json new file mode 100644 index 000000000..2008d1f67 --- /dev/null +++ b/worklenz-frontend/public/locales/es/settings/ratecard-settings.json @@ -0,0 +1,50 @@ +{ + "nameColumn": "Nombre", + "createdColumn": "Creado", + "noProjectsAvailable": "No hay proyectos disponibles", + "deleteConfirmationTitle": "¿Está seguro de que desea eliminar esta tarjeta de tarifas?", + "deleteConfirmationOk": "Sí, eliminar", + "deleteConfirmationCancel": "Cancelar", + "searchPlaceholder": "Buscar tarjetas de tarifas por nombre", + "createRatecard": "Crear Tarjeta de Tarifas", + "editTooltip": "Editar tarjeta de tarifas", + "deleteTooltip": "Eliminar tarjeta de tarifas", + "fetchError": "Error al cargar las tarjetas de tarifas", + "createError": "Error al crear la tarjeta de tarifas", + "deleteSuccess": "Tarjeta de tarifas eliminada con éxito", + "deleteError": "Error al eliminar la tarjeta de tarifas", + + "jobTitleColumn": "Título del trabajo", + "ratePerHourColumn": "Tarifa por hora", + "saveButton": "Guardar", + "addRoleButton": "Añadir Rol", + "createRatecardSuccessMessage": "Tarjeta de tarifas creada con éxito", + "createRatecardErrorMessage": "Error al crear la tarjeta de tarifas", + "updateRatecardSuccessMessage": "Tarjeta de tarifas actualizada con éxito", + "updateRatecardErrorMessage": "Error al actualizar la tarjeta de tarifas", + "currency": "Moneda", + "actionsColumn": "Acciones", + "addAllButton": "Añadir Todo", + "removeAllButton": "Eliminar Todo", + "selectJobTitle": "Seleccionar título del trabajo", + "unsavedChangesTitle": "Tiene cambios sin guardar", + "unsavedChangesMessage": "¿Desea guardar los cambios antes de salir?", + "unsavedChangesSave": "Guardar", + "unsavedChangesDiscard": "Descartar", + "ratecardNameRequired": "El nombre de la tarjeta de tarifas es obligatorio", + "ratecardNamePlaceholder": "Ingrese el nombre de la tarjeta de tarifas", + "noRatecardsFound": "No se encontraron tarjetas de tarifas", + "loadingRateCards": "Cargando tarjetas de tarifas...", + "noJobTitlesAvailable": "No hay títulos de trabajo disponibles", + "noRolesAdded": "Aún no se han añadido roles", + "createFirstJobTitle": "Crear Primer Título de Trabajo", + "jobRolesTitle": "Roles de Trabajo", + "noJobTitlesMessage": "Por favor, cree títulos de trabajo primero en la configuración de Títulos de Trabajo antes de añadir roles a las tarjetas de tarifas.", + "createNewJobTitle": "Crear Nuevo Título de Trabajo", + "jobTitleNamePlaceholder": "Ingrese el nombre del título de trabajo", + "jobTitleNameRequired": "El nombre del título de trabajo es obligatorio", + "jobTitleCreatedSuccess": "Título de trabajo creado con éxito", + "jobTitleCreateError": "Error al crear el título de trabajo", + "createButton": "Crear", + "cancelButton": "Cancelar" +} 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 c3980da8d..c4c830cee 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json @@ -23,6 +23,7 @@ "show-start-date": "Mostrar fecha de inicio", "hours": "Horas", "minutes": "Minutos", + "time-estimation-disabled-tooltip": "La estimación de tiempo está deshabilitada porque esta tarea tiene {{count}} subtareas. La estimación mostrada es la suma de todas las subtareas.", "progressValue": "Valor de Progreso", "progressValueTooltip": "Establecer el porcentaje de progreso (0-100%)", "progressValueRequired": "Por favor, introduce un valor de progreso", @@ -79,7 +80,21 @@ "addTimeLog": "Añadir nuevo registro de tiempo", "totalLogged": "Total registrado", "exportToExcel": "Exportar a Excel", - "noTimeLogsFound": "No se encontraron registros de tiempo" + "noTimeLogsFound": "No se encontraron registros de tiempo", + "timerDisabledTooltip": "El temporizador está deshabilitado porque esta tarea tiene {{count}} subtareas. El tiempo debe registrarse en las subtareas individuales.", + "timeLogDisabledTooltip": "El registro de tiempo está deshabilitado porque esta tarea tiene {{count}} subtareas. El tiempo debe registrarse en las subtareas individuales.", + "date": "Fecha", + "startTime": "Hora de inicio", + "endTime": "Hora de finalización", + "workDescription": "Descripción del trabajo", + "requiredFields": "Por favor, complete todos los campos requeridos", + "dateRequired": "Por favor, seleccione una fecha", + "startTimeRequired": "Por favor, seleccione la hora de inicio", + "endTimeRequired": "Por favor, seleccione la hora de finalización", + "workDescriptionPlaceholder": "Añadir una descripción", + "cancel": "Cancelar", + "logTime": "Registrar tiempo", + "updateTime": "Actualizar tiempo" }, "taskActivityLogTab": { "title": "Registro de actividad" diff --git a/worklenz-frontend/public/locales/es/time-report.json b/worklenz-frontend/public/locales/es/time-report.json index a602ec1d2..eb99c8e5b 100644 --- a/worklenz-frontend/public/locales/es/time-report.json +++ b/worklenz-frontend/public/locales/es/time-report.json @@ -5,6 +5,7 @@ "searchByName": "Buscar por nombre", "selectAll": "Seleccionar Todo", + "clearAll": "Limpiar Todo", "teams": "Equipos", "searchByProject": "Buscar por nombre de proyecto", @@ -15,6 +16,8 @@ "billable": "Facturable", "nonBillable": "No Facturable", + "filterByBillableStatus": "Filtrar por Estado Facturable", + "allBillableTypes": "Todos los Tipos Facturables", "total": "Total", @@ -40,5 +43,25 @@ "noCategory": "No Categoría", "noProjects": "No se encontraron proyectos", "noTeams": "No se encontraron equipos", - "noData": "No se encontraron datos" + "noData": "No se encontraron datos", + "members": "Miembros", + "searchByMember": "Buscar por miembro", + "utilization": "Utilización", + + "totalTimeLogged": "Tiempo Total Registrado", + "expectedCapacity": "Capacidad Esperada", + "teamUtilization": "Utilización del Equipo", + "variance": "Varianza", + "acrossAllTeamMembers": "En todos los miembros del equipo", + "basedOnWorkingSchedule": "Basado en horario de trabajo", + "optimal": "Óptimo", + "underUtilized": "Sub-utilizado", + "overUtilized": "Sobre-utilizado", + "overCapacity": "Sobre capacidad", + "underCapacity": "Bajo capacidad", + "considerWorkloadRedistribution": "Considerar redistribución de carga de trabajo", + "capacityAvailableForNewProjects": "Capacidad disponible para nuevos proyectos", + "targetRange": "Objetivo: 90-110%", + "overtimeWork": "Trabajo de Horas Extras", + "reviewWorkLifeBalance": "Revisar políticas de equilibrio trabajo-vida" } diff --git a/worklenz-frontend/public/locales/pt/admin-center/overview.json b/worklenz-frontend/public/locales/pt/admin-center/overview.json index 7cce8587b..6f1160671 100644 --- a/worklenz-frontend/public/locales/pt/admin-center/overview.json +++ b/worklenz-frontend/public/locales/pt/admin-center/overview.json @@ -4,5 +4,19 @@ "owner": "Proprietário da Organização", "admins": "Administradores da Organização", "contactNumber": "Adicione o Número de Contato", - "edit": "Editar" + "edit": "Editar", + "organizationWorkingDaysAndHours": "Dias e Horas de Trabalho da Organização", + "workingDays": "Dias de Trabalho", + "workingHours": "Horas de Trabalho", + "monday": "Segunda-feira", + "tuesday": "Terça-feira", + "wednesday": "Quarta-feira", + "thursday": "Quinta-feira", + "friday": "Sexta-feira", + "saturday": "Sábado", + "sunday": "Domingo", + "hours": "horas", + "saveButton": "Salvar", + "saved": "Salvo com sucesso!", + "errorSaving": "Erro ao salvar as configurações." } diff --git a/worklenz-frontend/public/locales/pt/project-view-finance.json b/worklenz-frontend/public/locales/pt/project-view-finance.json new file mode 100644 index 000000000..7634b6661 --- /dev/null +++ b/worklenz-frontend/public/locales/pt/project-view-finance.json @@ -0,0 +1,45 @@ +{ + "financeText": "Finanças", + "ratecardSingularText": "Cartão de Taxa", + "groupByText": "Agrupar por", + "statusText": "Status", + "phaseText": "Fase", + "priorityText": "Prioridade", + "exportButton": "Exportar", + "currencyText": "Moeda", + "importButton": "Importar", + "filterText": "Filtro", + "billableOnlyText": "Apenas Faturável", + "nonBillableOnlyText": "Apenas Não Faturável", + "allTasksText": "Todas as Tarefas", + + "taskColumn": "Tarefa", + "membersColumn": "Membros", + "hoursColumn": "Horas Estimadas", + "totalTimeLoggedColumn": "Tempo Total Registrado", + "costColumn": "Custo Real", + "estimatedCostColumn": "Custo Estimado", + "fixedCostColumn": "Custo Fixo", + "totalBudgetedCostColumn": "Custo Total Orçado", + "totalActualCostColumn": "Custo Real Total", + "varianceColumn": "Variância", + "totalText": "Total", + "noTasksFound": "Nenhuma tarefa encontrada", + + "addRoleButton": "+ Adicionar Função", + "ratecardImportantNotice": "* Este cartão de taxa é gerado com base nos títulos de trabalho e taxas padrão da empresa. No entanto, você tem a flexibilidade de modificá-lo de acordo com o projeto. Essas alterações não afetarão os títulos de trabalho e taxas padrão da organização.", + "saveButton": "Salvar", + + "jobTitleColumn": "Título do Trabalho", + "ratePerHourColumn": "Taxa por hora", + "ratecardPluralText": "Cartões de Taxa", + "labourHoursColumn": "Horas de Trabalho", + "actions": "Ações", + "selectJobTitle": "Selecionar Título do Trabalho", + "ratecardsPluralText": "Modelos de Cartão de Taxa", + "deleteConfirm": "Tem certeza?", + "yes": "Sim", + "no": "Não", + "alreadyImportedRateCardMessage": "Um cartão de taxa já foi importado. Limpe todos os cartões de taxa importados para adicionar um novo." +} + \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/reporting-members.json b/worklenz-frontend/public/locales/pt/reporting-members.json index a8035dcd5..dfc68e897 100644 --- a/worklenz-frontend/public/locales/pt/reporting-members.json +++ b/worklenz-frontend/public/locales/pt/reporting-members.json @@ -31,5 +31,10 @@ "todoText": "To Do", "doingText": "Doing", - "doneText": "Done" + "doneText": "Done", + + "timeLogsColumn": "Registros de Tempo", + "timeLogsColumnTooltip": "Mostra a proporção de tempo faturável vs não faturável", + "billable": "Faturável", + "nonBillable": "Não Faturável" } diff --git a/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json b/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json new file mode 100644 index 000000000..9fe817603 --- /dev/null +++ b/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json @@ -0,0 +1,50 @@ +{ + "nameColumn": "Nome", + "createdColumn": "Criado", + "noProjectsAvailable": "Nenhum projeto disponível", + "deleteConfirmationTitle": "Tem certeza que deseja excluir esta tabela de preços?", + "deleteConfirmationOk": "Sim, excluir", + "deleteConfirmationCancel": "Cancelar", + "searchPlaceholder": "Pesquisar tabelas de preços por nome", + "createRatecard": "Criar Tabela de Preços", + "editTooltip": "Editar tabela de preços", + "deleteTooltip": "Excluir tabela de preços", + "fetchError": "Falha ao carregar tabelas de preços", + "createError": "Falha ao criar tabela de preços", + "deleteSuccess": "Tabela de preços excluída com sucesso", + "deleteError": "Falha ao excluir tabela de preços", + + "jobTitleColumn": "Cargo", + "ratePerHourColumn": "Taxa por hora", + "saveButton": "Salvar", + "addRoleButton": "Adicionar Cargo", + "createRatecardSuccessMessage": "Tabela de preços criada com sucesso", + "createRatecardErrorMessage": "Falha ao criar tabela de preços", + "updateRatecardSuccessMessage": "Tabela de preços atualizada com sucesso", + "updateRatecardErrorMessage": "Falha ao atualizar tabela de preços", + "currency": "Moeda", + "actionsColumn": "Ações", + "addAllButton": "Adicionar Todos", + "removeAllButton": "Remover Todos", + "selectJobTitle": "Selecionar cargo", + "unsavedChangesTitle": "Você tem alterações não salvas", + "unsavedChangesMessage": "Deseja salvar suas alterações antes de sair?", + "unsavedChangesSave": "Salvar", + "unsavedChangesDiscard": "Descartar", + "ratecardNameRequired": "O nome da tabela de preços é obrigatório", + "ratecardNamePlaceholder": "Digite o nome da tabela de preços", + "noRatecardsFound": "Nenhuma tabela de preços encontrada", + "loadingRateCards": "Carregando tabelas de preços...", + "noJobTitlesAvailable": "Nenhum cargo disponível", + "noRolesAdded": "Nenhum cargo adicionado ainda", + "createFirstJobTitle": "Criar Primeiro Cargo", + "jobRolesTitle": "Cargos", + "noJobTitlesMessage": "Por favor, crie cargos primeiro nas configurações de Cargos antes de adicionar funções às tabelas de preços.", + "createNewJobTitle": "Criar Novo Cargo", + "jobTitleNamePlaceholder": "Digite o nome do cargo", + "jobTitleNameRequired": "O nome do cargo é obrigatório", + "jobTitleCreatedSuccess": "Cargo criado com sucesso", + "jobTitleCreateError": "Falha ao criar cargo", + "createButton": "Criar", + "cancelButton": "Cancelar" +} 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 6288af92e..651ee0416 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json @@ -23,7 +23,8 @@ "show-start-date": "Mostrar data de início", "hours": "Horas", "minutes": "Minutos", - "progressValue": "Valor de Progresso", + "time-estimation-disabled-tooltip": "A estimativa de tempo está desabilitada porque esta tarefa tem {{count}} subtarefas. A estimativa mostrada é a soma de todas as subtarefas.", + "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", @@ -79,7 +80,21 @@ "addTimeLog": "Adicionar novo registro de tempo", "totalLogged": "Total registrado", "exportToExcel": "Exportar para Excel", - "noTimeLogsFound": "Nenhum registro de tempo encontrado" + "noTimeLogsFound": "Nenhum registro de tempo encontrado", + "timerDisabledTooltip": "O cronômetro está desabilitado porque esta tarefa tem {{count}} subtarefas. O tempo deve ser registrado nas subtarefas individuais.", + "timeLogDisabledTooltip": "O registro de tempo está desabilitado porque esta tarefa tem {{count}} subtarefas. O tempo deve ser registrado nas subtarefas individuais.", + "date": "Data", + "startTime": "Hora de início", + "endTime": "Hora de término", + "workDescription": "Descrição do trabalho", + "requiredFields": "Por favor, preencha todos os campos obrigatórios", + "dateRequired": "Por favor, selecione uma data", + "startTimeRequired": "Por favor, selecione a hora de início", + "endTimeRequired": "Por favor, selecione a hora de término", + "workDescriptionPlaceholder": "Adicionar uma descrição", + "cancel": "Cancelar", + "logTime": "Registrar tempo", + "updateTime": "Atualizar tempo" }, "taskActivityLogTab": { "title": "Registro de atividade" diff --git a/worklenz-frontend/public/locales/pt/time-report.json b/worklenz-frontend/public/locales/pt/time-report.json index 8d09db4c6..2467274d2 100644 --- a/worklenz-frontend/public/locales/pt/time-report.json +++ b/worklenz-frontend/public/locales/pt/time-report.json @@ -5,6 +5,7 @@ "searchByName": "Pesquisar por nome", "selectAll": "Selecionar Todos", + "clearAll": "Limpar Todos", "teams": "Equipes", "searchByProject": "Pesquisar por nome do projeto", @@ -15,6 +16,8 @@ "billable": "Cobrável", "nonBillable": "Não Cobrável", + "filterByBillableStatus": "Filtrar por Status de Cobrança", + "allBillableTypes": "Todos os Tipos Cobráveis", "total": "Total", @@ -40,5 +43,25 @@ "noCategory": "Nenhuma Categoria", "noProjects": "Nenhum projeto encontrado", "noTeams": "Nenhum time encontrado", - "noData": "Nenhum dado encontrado" + "noData": "Nenhum dado encontrado", + "members": "Membros", + "searchByMember": "Pesquisar por membro", + "utilization": "Utilização", + + "totalTimeLogged": "Tempo Total Registrado", + "expectedCapacity": "Capacidade Esperada", + "teamUtilization": "Utilização da Equipe", + "variance": "Variância", + "acrossAllTeamMembers": "Em todos os membros da equipe", + "basedOnWorkingSchedule": "Baseado no horário de trabalho", + "optimal": "Ótimo", + "underUtilized": "Sub-utilizado", + "overUtilized": "Super-utilizado", + "overCapacity": "Acima da capacidade", + "underCapacity": "Abaixo da capacidade", + "considerWorkloadRedistribution": "Considerar redistribuição da carga de trabalho", + "capacityAvailableForNewProjects": "Capacidade disponível para novos projetos", + "targetRange": "Meta: 90-110%", + "overtimeWork": "Trabalho de Horas Extras", + "reviewWorkLifeBalance": "Revisar políticas de equilíbrio trabalho-vida" } diff --git a/worklenz-frontend/scripts/copy-tinymce.js b/worklenz-frontend/scripts/copy-tinymce.js index 8f801c467..45396759d 100644 --- a/worklenz-frontend/scripts/copy-tinymce.js +++ b/worklenz-frontend/scripts/copy-tinymce.js @@ -1,5 +1,10 @@ -const fs = require('fs'); -const path = require('path'); +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// Get __dirname equivalent for ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); // Create the directory if it doesn't exist const targetDir = path.join(__dirname, '..', 'public', 'tinymce'); diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 3181a25e8..c5753f6b3 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -6,6 +6,7 @@ import i18next from 'i18next'; // Components import ThemeWrapper from './features/theme/ThemeWrapper'; import PreferenceSelector from './components/PreferenceSelector'; +import ResourcePreloader from './components/resource-preloader/resource-preloader'; // Routes import router from './app/routes'; @@ -20,7 +21,7 @@ import { Language } from './features/i18n/localesSlice'; import logger from './utils/errorLogger'; import { SuspenseFallback } from './components/suspense-fallback/suspense-fallback'; -const App: React.FC<{ children: React.ReactNode }> = ({ children }) => { +const App: React.FC<{ children?: React.ReactNode }> = ({ children }) => { const themeMode = useAppSelector(state => state.themeReducer.mode); const language = useAppSelector(state => state.localesReducer.lng); @@ -47,6 +48,7 @@ const App: React.FC<{ children: React.ReactNode }> = ({ children }) => { }> + ); diff --git a/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts b/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts new file mode 100644 index 000000000..6546dbf9f --- /dev/null +++ b/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts @@ -0,0 +1,81 @@ +import apiClient from '@api/api-client'; +import { API_BASE_URL } from '@/shared/constants'; +import { IServerResponse } from '@/types/common.types'; +import { IJobType, JobRoleType } from '@/types/project/ratecard.types'; + +const rootUrl = `${API_BASE_URL}/project-rate-cards`; + +export interface IProjectRateCardRole { + id?: string; + project_id: string; + job_title_id: string; + jobtitle?: string; + rate: number; + data?: object; + roles?: IJobType[]; +} + +export const projectRateCardApiService = { + // Insert multiple roles for a project + async insertMany(project_id: string, roles: Omit[]): Promise> { + const response = await apiClient.post>(rootUrl, { project_id, roles }); + return response.data; + }, + // Insert a single role for a project + async insertOne({ project_id, job_title_id, rate }: { project_id: string; job_title_id: string; rate: number }): Promise> { + const response = await apiClient.post>( + `${rootUrl}/create-project-rate-card-role`, + { project_id, job_title_id, rate } + ); + return response.data; + }, + + // Get all roles for a project + async getFromProjectId(project_id: string): Promise> { + const response = await apiClient.get>(`${rootUrl}/project/${project_id}`); + return response.data; + }, + + // Get a single role by id + async getFromId(id: string): Promise> { + const response = await apiClient.get>(`${rootUrl}/${id}`); + return response.data; + }, + + // Update a single role by id + async updateFromId(id: string, body: { job_title_id: string; rate: string }): Promise> { + const response = await apiClient.put>(`${rootUrl}/${id}`, body); + return response.data; + }, + + // Update all roles for a project (delete then insert) + async updateFromProjectId(project_id: string, roles: Omit[]): Promise> { + const response = await apiClient.put>(`${rootUrl}/project/${project_id}`, { project_id, roles }); + return response.data; + }, + + // Update project member rate card role + async updateMemberRateCardRole( + project_id: string, + member_id: string, + project_rate_card_role_id: string + ): Promise> { + const response = await apiClient.put>( + `${rootUrl}/project/${project_id}/members/${member_id}/rate-card-role`, + { project_rate_card_role_id } + ); + return response.data; + }, + + // Delete a single role by id + async deleteFromId(id: string): Promise> { + const response = await apiClient.delete>(`${rootUrl}/${id}`); + return response.data; + }, + + // Delete all roles for a project + async deleteFromProjectId(project_id: string): Promise> { + const response = await apiClient.delete>(`${rootUrl}/project/${project_id}`); + return response.data; + }, +}; \ No newline at end of file diff --git a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts new file mode 100644 index 000000000..187269308 --- /dev/null +++ b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts @@ -0,0 +1,92 @@ +import { API_BASE_URL } from "@/shared/constants"; +import { IServerResponse } from "@/types/common.types"; +import apiClient from "../api-client"; +import { IProjectFinanceResponse, ITaskBreakdownResponse, IProjectFinanceTask } from "@/types/project/project-finance.types"; + +const rootUrl = `${API_BASE_URL}/project-finance`; + +type BillableFilterType = 'all' | 'billable' | 'non-billable'; + +export const projectFinanceApiService = { + getProjectTasks: async ( + projectId: string, + groupBy: 'status' | 'priority' | 'phases' = 'status', + billableFilter: BillableFilterType = 'billable' + ): Promise> => { + const response = await apiClient.get>( + `${rootUrl}/project/${projectId}/tasks`, + { + params: { + group_by: groupBy, + billable_filter: billableFilter + } + } + ); + return response.data; + }, + + getSubTasks: async ( + projectId: string, + parentTaskId: string, + billableFilter: BillableFilterType = 'billable' + ): Promise> => { + const response = await apiClient.get>( + `${rootUrl}/project/${projectId}/tasks/${parentTaskId}/subtasks`, + { + params: { + billable_filter: billableFilter + } + } + ); + return response.data; + }, + + getTaskBreakdown: async ( + taskId: string + ): Promise> => { + const response = await apiClient.get>( + `${rootUrl}/task/${taskId}/breakdown` + ); + return response.data; + }, + + updateTaskFixedCost: async ( + taskId: string, + fixedCost: number + ): Promise> => { + const response = await apiClient.put>( + `${rootUrl}/task/${taskId}/fixed-cost`, + { fixed_cost: fixedCost } + ); + return response.data; + }, + + updateProjectCurrency: async ( + projectId: string, + currency: string + ): Promise> => { + const response = await apiClient.put>( + `${rootUrl}/project/${projectId}/currency`, + { currency } + ); + return response.data; + }, + + exportFinanceData: async ( + projectId: string, + groupBy: 'status' | 'priority' | 'phases' = 'status', + billableFilter: BillableFilterType = 'billable' + ): Promise => { + const response = await apiClient.get( + `${rootUrl}/project/${projectId}/export`, + { + params: { + groupBy, + billable_filter: billableFilter + }, + responseType: 'blob' + } + ); + return response.data; + }, +} \ No newline at end of file diff --git a/worklenz-frontend/src/api/projects/projects.api.service.ts b/worklenz-frontend/src/api/projects/projects.api.service.ts index 0297dd225..27cf992d7 100644 --- a/worklenz-frontend/src/api/projects/projects.api.service.ts +++ b/worklenz-frontend/src/api/projects/projects.api.service.ts @@ -7,6 +7,7 @@ import { IProjectViewModel } from '@/types/project/projectViewModel.types'; import { ITeamMemberOverviewGetResponse } from '@/types/project/project-insights.types'; import { IProjectMembersViewModel } from '@/types/projectMember.types'; import { IProjectManager } from '@/types/project/projectManager.types'; +import { ITaskPhase } from '@/types/tasks/taskPhase.types'; const rootUrl = `${API_BASE_URL}/projects`; @@ -120,5 +121,14 @@ export const projectsApiService = { const response = await apiClient.get>(`${url}`); return response.data; }, + + updateProjectPhaseLabel: async (projectId: string, phaseLabel: string) => { + const q = toQueryString({ id: projectId, current_project_id: projectId }); + const response = await apiClient.put>( + `${rootUrl}/label/${projectId}${q}`, + { name: phaseLabel } + ); + return response.data; + }, }; diff --git a/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.ts b/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.ts index 1529d46b1..488a06195 100644 --- a/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.ts +++ b/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.ts @@ -3,7 +3,7 @@ import { toQueryString } from '@/utils/toQueryString'; import apiClient from '../api-client'; import { IServerResponse } from '@/types/common.types'; import { IAllocationViewModel } from '@/types/reporting/reporting-allocation.types'; -import { IProjectLogsBreakdown, IRPTTimeMember, IRPTTimeProject, ITimeLogBreakdownReq } from '@/types/reporting/reporting.types'; +import { IProjectLogsBreakdown, IRPTTimeMember, IRPTTimeMemberViewModel, IRPTTimeProject, ITimeLogBreakdownReq } from '@/types/reporting/reporting.types'; const rootUrl = `${API_BASE_URL}/reporting`; @@ -25,7 +25,7 @@ export const reportingTimesheetApiService = { return response.data; }, - getMemberTimeSheets: async (body = {}, archived = false): Promise> => { + getMemberTimeSheets: async (body = {}, archived = false): Promise> => { const q = toQueryString({ archived }); const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, body); return response.data; diff --git a/worklenz-frontend/src/api/settings/rate-cards/rate-cards.api.service.ts b/worklenz-frontend/src/api/settings/rate-cards/rate-cards.api.service.ts new file mode 100644 index 000000000..6007474f3 --- /dev/null +++ b/worklenz-frontend/src/api/settings/rate-cards/rate-cards.api.service.ts @@ -0,0 +1,48 @@ +import apiClient from '@api/api-client'; +import { API_BASE_URL } from '@/shared/constants'; +import { IServerResponse } from '@/types/common.types'; +import { IJobTitle, IJobTitlesViewModel } from '@/types/job.types'; +import { toQueryString } from '@/utils/toQueryString'; +import { RatecardType, IRatecardViewModel } from '@/types/project/ratecard.types'; + +type IRatecard = { + id: string;} + +const rootUrl = `${API_BASE_URL}/rate-cards`; + +export const rateCardApiService = { + async getRateCards( + index: number, + size: number, + field: string | null, + order: string | null, + search?: string | null + ): Promise> { + const s = encodeURIComponent(search || ''); + const queryString = toQueryString({ index, size, field, order, search: s }); + const response = await apiClient.get>( + `${rootUrl}${queryString}` + ); + return response.data; + }, + async getRateCardById(id: string): Promise> { + const response = await apiClient.get>(`${rootUrl}/${id}`); + return response.data; + }, + + async createRateCard(body: RatecardType): Promise> { + const response = await apiClient.post>(rootUrl, body); + return response.data; + }, + + async updateRateCard(id: string, body: RatecardType): Promise> { + const response = await apiClient.put>(`${rootUrl}/${id}`, body); + return response.data; + }, + + async deleteRateCard(id: string): Promise> { + const response = await apiClient.delete>(`${rootUrl}/${id}`); + return response.data; + }, + +}; \ No newline at end of file diff --git a/worklenz-frontend/src/app/store.ts b/worklenz-frontend/src/app/store.ts index 6bf7adcf1..7ce0ccdcf 100644 --- a/worklenz-frontend/src/app/store.ts +++ b/worklenz-frontend/src/app/store.ts @@ -69,9 +69,11 @@ import projectReportsTableColumnsReducer from '../features/reporting/projectRepo import projectReportsReducer from '../features/reporting/projectReports/project-reports-slice'; import membersReportsReducer from '../features/reporting/membersReports/membersReportsSlice'; import timeReportsOverviewReducer from '@features/reporting/time-reports/time-reports-overview.slice'; - +import financeReducer from '../features/finance/finance-slice'; import roadmapReducer from '../features/roadmap/roadmap-slice'; import teamMembersReducer from '@features/team-members/team-members.slice'; +import projectFinanceRateCardReducer from '../features/finance/project-finance-slice'; +import projectFinancesReducer from '../features/projects/finance/project-finance.slice'; import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice'; import homePageApiService from '@/api/home-page/home-page.api.service'; import { projectsApi } from '@/api/projects/projects.v1.api.service'; @@ -155,6 +157,9 @@ export const store = configureStore({ roadmapReducer: roadmapReducer, groupByFilterDropdownReducer: groupByFilterDropdownReducer, timeReportsOverviewReducer: timeReportsOverviewReducer, + financeReducer: financeReducer, + projectFinanceRateCard: projectFinanceRateCardReducer, + projectFinances: projectFinancesReducer, }, }); diff --git a/worklenz-frontend/src/components/avatars/avatars.tsx b/worklenz-frontend/src/components/avatars/avatars.tsx index 753c6378b..4490769d7 100644 --- a/worklenz-frontend/src/components/avatars/avatars.tsx +++ b/worklenz-frontend/src/components/avatars/avatars.tsx @@ -4,19 +4,20 @@ import { InlineMember } from '@/types/teamMembers/inlineMember.types'; interface AvatarsProps { members: InlineMember[]; maxCount?: number; + allowClickThrough?: boolean; } -const renderAvatar = (member: InlineMember, index: number) => ( +const renderAvatar = (member: InlineMember, index: number, allowClickThrough: boolean = false) => ( {member.avatar_url ? ( - e.stopPropagation()}> + e.stopPropagation()}> ) : ( - e.stopPropagation()}> + e.stopPropagation()}> ( ); -const Avatars: React.FC = ({ members, maxCount }) => { +const Avatars: React.FC = ({ members, maxCount, allowClickThrough = false }) => { const visibleMembers = maxCount ? members.slice(0, maxCount) : members; return ( -
e.stopPropagation()}> +
e.stopPropagation()}> - {visibleMembers.map((member, index) => renderAvatar(member, index))} + {visibleMembers.map((member, index) => renderAvatar(member, index, allowClickThrough))}
); diff --git a/worklenz-frontend/src/components/conditional-alert.tsx b/worklenz-frontend/src/components/conditional-alert.tsx new file mode 100644 index 000000000..acddfe385 --- /dev/null +++ b/worklenz-frontend/src/components/conditional-alert.tsx @@ -0,0 +1,61 @@ +import { Alert } from 'antd'; +import { useState, useEffect } from 'react'; + +interface ConditionalAlertProps { + message?: string; + type?: 'success' | 'info' | 'warning' | 'error'; + showInitially?: boolean; + onClose?: () => void; + condition?: boolean; + className?: string; +} + +const ConditionalAlert = ({ + message = '', + type = 'info', + showInitially = false, + onClose, + condition, + className = '' +}: ConditionalAlertProps) => { + const [visible, setVisible] = useState(showInitially); + + useEffect(() => { + if (condition !== undefined) { + setVisible(condition); + } + }, [condition]); + + const handleClose = () => { + setVisible(false); + onClose?.(); + }; + + const alertStyles = { + position: 'fixed', + top: 0, + left: 0, + right: 0, + zIndex: 1000, + margin: 0, + borderRadius: 0, + } as const; + + if (!visible || !message) { + return null; + } + + return ( + + ); +}; + +export default ConditionalAlert; \ No newline at end of file diff --git a/worklenz-frontend/src/components/license-alert.tsx b/worklenz-frontend/src/components/license-alert.tsx new file mode 100644 index 000000000..7a8070493 --- /dev/null +++ b/worklenz-frontend/src/components/license-alert.tsx @@ -0,0 +1,184 @@ +import { Alert, Button, Space } from 'antd'; +import { useState, useEffect } from 'react'; +import { CrownOutlined, ClockCircleOutlined } from '@ant-design/icons'; +import { ILocalSession } from '@/types/auth/local-session.types'; +import { LICENSE_ALERT_KEY } from '@/shared/constants'; +import { format, isSameDay, differenceInDays, addDays, isAfter } from 'date-fns'; +import { useNavigate } from 'react-router-dom'; + +interface LicenseAlertProps { + currentSession: ILocalSession; + onVisibilityChange?: (visible: boolean) => void; +} + +interface AlertConfig { + type: 'success' | 'info' | 'warning' | 'error'; + message: React.ReactNode; + description: string; + icon: React.ReactNode; + licenseType: 'trial' | 'expired' | 'expiring'; + daysRemaining: number; +} + +const LicenseAlert = ({ currentSession, onVisibilityChange }: LicenseAlertProps) => { + const navigate = useNavigate(); + const [visible, setVisible] = useState(false); + const [alertConfig, setAlertConfig] = useState(null); + + const handleClose = () => { + setVisible(false); + setLastAlertDate(new Date()); + }; + + const getLastAlertDate = () => { + const lastAlertDate = localStorage.getItem(LICENSE_ALERT_KEY); + return lastAlertDate ? new Date(lastAlertDate) : null; + }; + + const setLastAlertDate = (date: Date) => { + localStorage.setItem(LICENSE_ALERT_KEY, format(date, 'yyyy-MM-dd')); + }; + + const handleUpgrade = () => { + navigate('/worklenz/admin-center/billing'); + }; + + const handleExtend = () => { + navigate('/worklenz/admin-center/billing'); + }; + + const getVisibleAndConfig = (): { visible: boolean; config: AlertConfig | null } => { + const lastAlertDate = getLastAlertDate(); + + // Check if alert was already shown today + if (lastAlertDate && isSameDay(lastAlertDate, new Date())) { + return { visible: false, config: null }; + } + + if (!currentSession.valid_till_date) { + return { visible: false, config: null }; + } + + let validTillDate = new Date(currentSession.valid_till_date); + const today = new Date(); + + // If validTillDate is after today, add 1 day (matching Angular logic) + if (isAfter(validTillDate, today)) { + validTillDate = addDays(validTillDate, 1); + } + + // Calculate the difference in days between the two dates + const daysDifference = differenceInDays(validTillDate, today); + + // Don't show if no valid_till_date or difference is >= 7 days + if (daysDifference >= 7) { + return { visible: false, config: null }; + } + + const absDaysDifference = Math.abs(daysDifference); + const dayText = `${absDaysDifference} day${absDaysDifference === 1 ? '' : 's'}`; + + let string1 = ''; + let string2 = dayText; + let licenseType: 'trial' | 'expired' | 'expiring' = 'expiring'; + let alertType: 'success' | 'info' | 'warning' | 'error' = 'warning'; + + if (currentSession.subscription_status === 'trialing') { + licenseType = 'trial'; + if (daysDifference < 0) { + string1 = 'Your Worklenz trial expired'; + string2 = string2 + ' ago'; + alertType = 'error'; + licenseType = 'expired'; + } else if (daysDifference !== 0 && daysDifference < 7) { + string1 = 'Your Worklenz trial expires in'; + } else if (daysDifference === 0 && daysDifference < 7) { + string1 = 'Your Worklenz trial expires'; + string2 = 'today'; + } + } else if (currentSession.subscription_status === 'active') { + if (daysDifference < 0) { + string1 = 'Your Worklenz subscription expired'; + string2 = string2 + ' ago'; + alertType = 'error'; + licenseType = 'expired'; + } else if (daysDifference !== 0 && daysDifference < 7) { + string1 = 'Your Worklenz subscription expires in'; + } else if (daysDifference === 0 && daysDifference < 7) { + string1 = 'Your Worklenz subscription expires'; + string2 = 'today'; + } + } else { + return { visible: false, config: null }; + } + + const config: AlertConfig = { + type: alertType, + message: ( + <> + Action required! {string1} {string2} + + ), + description: '', + icon: licenseType === 'expired' || licenseType === 'trial' ? : , + licenseType, + daysRemaining: absDaysDifference + }; + + return { visible: true, config }; + }; + + useEffect(() => { + const { visible: shouldShow, config } = getVisibleAndConfig(); + setVisible(shouldShow); + setAlertConfig(config); + + // Notify parent about visibility change + if (onVisibilityChange) { + onVisibilityChange(shouldShow); + } + }, [currentSession, onVisibilityChange]); + + const alertStyles = { + margin: 0, + borderRadius: 0, + } as const; + + const actionButtons = alertConfig && ( + + {/* Show button only if user is owner or admin */} + {(currentSession.owner || currentSession.is_admin) && ( + + )} + + ); + + if (!visible || !alertConfig) { + return null; + } + + return ( +
+ +
+ ); +}; + +export default LicenseAlert; \ No newline at end of file diff --git a/worklenz-frontend/src/components/project-ratecard/ratecard-assignee-selector.tsx b/worklenz-frontend/src/components/project-ratecard/ratecard-assignee-selector.tsx new file mode 100644 index 000000000..dc2521d4b --- /dev/null +++ b/worklenz-frontend/src/components/project-ratecard/ratecard-assignee-selector.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef, useState } from 'react'; +import { InputRef } from 'antd/es/input'; +import Dropdown from 'antd/es/dropdown'; +import Card from 'antd/es/card'; +import List from 'antd/es/list'; +import Input from 'antd/es/input'; +import Checkbox from 'antd/es/checkbox'; +import Button from 'antd/es/button'; +import Empty from 'antd/es/empty'; +import { PlusOutlined } from '@ant-design/icons'; +import SingleAvatar from '../common/single-avatar/single-avatar'; +import { IProjectMemberViewModel } from '@/types/projectMember.types'; + +interface RateCardAssigneeSelectorProps { + projectId: string; + onChange?: (memberId: string) => void; + selectedMemberIds?: string[]; + memberlist?: IProjectMemberViewModel[]; +} + +const RateCardAssigneeSelector = ({ + projectId, + onChange, + selectedMemberIds = [], + memberlist = [], + assignedMembers = [], // New prop: List of all assigned member IDs across all job titles +}: RateCardAssigneeSelectorProps & { assignedMembers: string[] }) => { + const membersInputRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(''); + const [members, setMembers] = useState(memberlist); + + useEffect(() => { + setMembers(memberlist); + }, [memberlist]); + + const filteredMembers = members.filter((member) => + member.name?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const dropdownContent = ( + + setSearchQuery(e.currentTarget.value)} + placeholder="Search members" + /> + + {filteredMembers.length ? ( + filteredMembers.map((member) => { + const isAssignedToAnotherJobTitle = + assignedMembers.includes(member.id || '') && + !selectedMemberIds.includes(member.id || ''); // Check if the member is assigned elsewhere + + return ( + + onChange?.(member.id || '')} + /> + + {member.name} + + ); + }) + ) : ( + + )} + + + ); + + return ( + dropdownContent} + onOpenChange={(open) => { + if (open) setTimeout(() => membersInputRef.current?.focus(), 0); + }} + > +
); diff --git a/worklenz-frontend/src/components/schedule/grant-chart/tabs/withStartAndEndDates/WithStartAndEndDates.tsx b/worklenz-frontend/src/components/schedule/grant-chart/tabs/withStartAndEndDates/WithStartAndEndDates.tsx index c52356c45..34de81555 100644 --- a/worklenz-frontend/src/components/schedule/grant-chart/tabs/withStartAndEndDates/WithStartAndEndDates.tsx +++ b/worklenz-frontend/src/components/schedule/grant-chart/tabs/withStartAndEndDates/WithStartAndEndDates.tsx @@ -2,13 +2,14 @@ import { TaskType } from '@/types/task.types'; import { useAppSelector } from '@/hooks/useAppSelector'; import GroupByFilterDropdown from '@/components/project-task-filters/filter-dropdowns/group-by-filter-dropdown'; import { useTranslation } from 'react-i18next'; -import StatusGroupTables from '@/pages/projects/project-view-1/taskList/statusTables/StatusGroupTables'; +import TaskGroupList from '@/pages/projects/projectView/taskList/groupTables/TaskGroupList'; import PriorityGroupTables from '@/pages/projects/projectView/taskList/groupTables/priorityTables/PriorityGroupTables'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { ITaskListGroup } from '@/types/tasks/taskList.types'; const WithStartAndEndDates = () => { const dataSource: ITaskListGroup[] = useAppSelector(state => state.taskReducer.taskGroups); + const groupBy = useAppSelector(state => state.taskReducer.groupBy); const { t } = useTranslation('schedule'); return (
@@ -60,10 +61,7 @@ const WithStartAndEndDates = () => {
- {dataSource.map(group => ( - - ))} - {/* */} +
); diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-estimation/task-drawer-estimation.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-estimation/task-drawer-estimation.tsx index f3732d423..cf43f4e0d 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-estimation/task-drawer-estimation.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-estimation/task-drawer-estimation.tsx @@ -2,23 +2,41 @@ import { SocketEvents } from '@/shared/socket-events'; import { useSocket } from '@/socket/socketContext'; import { colors } from '@/styles/colors'; import { ITaskViewModel } from '@/types/tasks/task.types'; -import { Flex, Form, FormInstance, InputNumber, Typography } from 'antd'; +import { Flex, Form, FormInstance, InputNumber, Typography, Tooltip } from 'antd'; import { TFunction } from 'i18next'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; interface TaskDrawerEstimationProps { t: TFunction; task: ITaskViewModel; form: FormInstance; + subTasksEstimation?: { hours: number; minutes: number }; // Sum of subtasks estimation } -const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => { +const TaskDrawerEstimation = ({ t, task, form, subTasksEstimation }: TaskDrawerEstimationProps) => { const { socket, connected } = useSocket(); const [hours, setHours] = useState(0); const [minutes, setMinutes] = useState(0); + + // Check if task has subtasks + const hasSubTasks = (task?.sub_tasks_count || 0) > 0; + + // Use subtasks estimation if available, otherwise use task's own estimation + const displayHours = hasSubTasks && subTasksEstimation ? subTasksEstimation.hours : (task?.total_hours || 0); + const displayMinutes = hasSubTasks && subTasksEstimation ? subTasksEstimation.minutes : (task?.total_minutes || 0); + + useEffect(() => { + // Update form values when subtasks estimation changes + if (hasSubTasks && subTasksEstimation) { + form.setFieldsValue({ + hours: subTasksEstimation.hours, + minutes: subTasksEstimation.minutes + }); + } + }, [subTasksEstimation, hasSubTasks, form]); const handleTimeEstimationBlur = (e: React.FocusEvent) => { - if (!connected || !task.id) return; + if (!connected || !task.id || hasSubTasks) return; // Get current form values instead of using state const currentHours = form.getFieldValue('hours') || 0; @@ -35,48 +53,69 @@ const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => { ); }; + const tooltipTitle = hasSubTasks + ? t('taskInfoTab.details.time-estimation-disabled-tooltip', { + count: task?.sub_tasks_count || 0, + defaultValue: `Time estimation is disabled because this task has ${task?.sub_tasks_count || 0} subtasks. The estimation shown is the sum of all subtasks.` + }) + : ''; + return ( - - - {t('taskInfoTab.details.hours')} - - } - style={{ marginBottom: 36 }} - labelCol={{ style: { paddingBlock: 0 } }} - layout="vertical" - > - setHours(value || 0)} - /> - - - {t('taskInfoTab.details.minutes')} - - } - style={{ marginBottom: 36 }} - labelCol={{ style: { paddingBlock: 0 } }} - layout="vertical" - > - setMinutes(value || 0)} - /> - - + + + + {t('taskInfoTab.details.hours')} + + } + style={{ marginBottom: 36 }} + labelCol={{ style: { paddingBlock: 0 } }} + layout="vertical" + > + !hasSubTasks && setHours(value || 0)} + disabled={hasSubTasks} + value={displayHours} + style={{ + cursor: hasSubTasks ? 'not-allowed' : 'default', + opacity: hasSubTasks ? 0.6 : 1 + }} + /> + + + {t('taskInfoTab.details.minutes')} + + } + style={{ marginBottom: 36 }} + labelCol={{ style: { paddingBlock: 0 } }} + layout="vertical" + > + !hasSubTasks && setMinutes(value || 0)} + disabled={hasSubTasks} + value={displayMinutes} + style={{ + cursor: hasSubTasks ? 'not-allowed' : 'default', + opacity: hasSubTasks ? 0.6 : 1 + }} + /> + + + ); }; diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/subtask-table.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/subtask-table.tsx index 5d63c177a..45b48b962 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/subtask-table.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/subtask-table.tsx @@ -166,9 +166,11 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask { key: 'name', dataIndex: 'name', + title: 'Name', }, { key: 'priority', + title: 'Priority', render: (record: IProjectTask) => ( ( , }, { key: 'actionBtns', + title: 'Actions', width: 80, render: (record: IProjectTask) => ( @@ -230,7 +235,6 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask {subTasks.length > 0 && ( record?.id || nanoid()} diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx index 23dac1281..7c445b10b 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx @@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next'; import { colors } from '@/styles/colors'; import { ITaskFormViewModel, ITaskViewModel } from '@/types/tasks/task.types'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { ISubTask } from '@/types/tasks/subTask.types'; import { simpleDateFormat } from '@/utils/simpleDateFormat'; import NotifyMemberSelector from './notify-member-selector'; @@ -34,6 +35,7 @@ import { InlineMember } from '@/types/teamMembers/inlineMember.types'; interface TaskDetailsFormProps { taskFormViewModel?: ITaskFormViewModel | null; + subTasks?: ISubTask[]; // Array of subtasks to calculate estimation sum } // Custom wrapper that enforces stricter rules for displaying progress input @@ -75,11 +77,15 @@ const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps) return null; }; -const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => { +const TaskDetailsForm = ({ taskFormViewModel = null, subTasks = [] }: TaskDetailsFormProps) => { const { t } = useTranslation('task-drawer/task-drawer'); const [form] = Form.useForm(); const { project } = useAppSelector(state => state.projectReducer); + // No need to calculate subtask estimation on frontend anymore + // The backend now provides recursive estimation directly in the task data + const subTasksEstimation: { hours: number; minutes: number } | undefined = undefined; + useEffect(() => { if (!taskFormViewModel) { form.resetFields(); @@ -167,7 +173,7 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => - + {taskFormViewModel?.task && ( @@ -183,9 +189,9 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => - + {/* - + */} diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-drawer-info-tab.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-drawer-info-tab.tsx index e382e0520..85b1ea60a 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-drawer-info-tab.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-drawer-info-tab.tsx @@ -100,7 +100,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => { { key: 'details', label: {t('taskInfoTab.details.title')}, - children: , + children: , style: panelStyle, className: 'custom-task-drawer-info-collapse', }, diff --git a/worklenz-frontend/src/components/task-drawer/shared/time-log/task-drawer-time-log.tsx b/worklenz-frontend/src/components/task-drawer/shared/time-log/task-drawer-time-log.tsx index 2191dbc71..f1d9dc9ff 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/time-log/task-drawer-time-log.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/time-log/task-drawer-time-log.tsx @@ -25,8 +25,6 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) => const [totalTimeText, setTotalTimeText] = useState('0m 0s'); const [loading, setLoading] = useState(false); - const dispatch = useAppDispatch(); - const themeMode = useAppSelector(state => state.themeReducer.mode); const { selectedTaskId, taskFormViewModel, timeLogEditing } = useAppSelector( state => state.taskDrawerReducer ); @@ -36,6 +34,15 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) => taskFormViewModel?.task?.timer_start_time || null ); + // Check if task has subtasks + const hasSubTasks = (taskFormViewModel?.task?.sub_tasks_count || 0) > 0; + const timerDisabledTooltip = hasSubTasks + ? t('taskTimeLogTab.timerDisabledTooltip', { + count: taskFormViewModel?.task?.sub_tasks_count || 0, + defaultValue: `Timer is disabled because this task has ${taskFormViewModel?.task?.sub_tasks_count || 0} subtasks. Time should be logged on individual subtasks.` + }) + : ''; + const formatTimeComponents = (hours: number, minutes: number, seconds: number): string => { const parts = []; if (hours > 0) parts.push(`${hours}h`); @@ -131,6 +138,8 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) => handleStartTimer={handleStartTimer} handleStopTimer={handleTimerStop} timeString={timeString} + disabled={hasSubTasks} + disabledTooltip={timerDisabledTooltip} /> - + + + + diff --git a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.css b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.css index e69de29bb..a4d6b96f7 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.css +++ b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.css @@ -0,0 +1,35 @@ +.time-log-item .ant-card { + transition: all 0.2s ease; +} + +.time-log-item .ant-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + border-color: #d9d9d9; +} + +/* Dark mode hover effects */ +[data-theme='dark'] .time-log-item .ant-card:hover { + box-shadow: 0 2px 8px rgba(255, 255, 255, 0.15); + border-color: #434343; +} + +.time-log-item .ant-card .ant-card-body { + padding: 12px 16px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .time-log-item .ant-card { + margin-bottom: 6px; + } + + .time-log-item .ant-divider-vertical { + display: none; + } + + /* Stack time info vertically on mobile */ + .time-log-item .time-tracking-info { + flex-direction: column; + gap: 8px; + } +} diff --git a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.tsx b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.tsx index ff4964f35..81f84085d 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { Button, Divider, Flex, Popconfirm, Typography, Space } from 'antd'; +import { Button, Divider, Flex, Popconfirm, Typography, Space, Tag, Card } from 'antd'; +import { ClockCircleOutlined } from '@ant-design/icons'; import { colors } from '@/styles/colors'; import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types'; import SingleAvatar from '@/components/common/single-avatar/single-avatar'; @@ -12,6 +13,7 @@ import { useAppDispatch } from '@/hooks/useAppDispatch'; import { setTimeLogEditing } from '@/features/task-drawer/task-drawer.slice'; import TimeLogForm from './time-log-form'; import { useAuthService } from '@/hooks/useAuth'; +import { setRefreshTimestamp } from '@/features/project/project.slice'; type TimeLogItemProps = { log: ITaskLogViewModel; @@ -19,20 +21,18 @@ type TimeLogItemProps = { }; const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => { - const { user_name, avatar_url, time_spent_text, logged_by_timer, created_at, user_id, description } = log; - const { selectedTaskId } = useAppSelector(state => state.taskDrawerReducer); + const { user_name, avatar_url, time_spent_text, logged_by_timer, created_at, user_id, description, task_name, task_id, start_time, end_time } = log; + const { selectedTaskId, taskFormViewModel } = useAppSelector(state => state.taskDrawerReducer); + const themeMode = useAppSelector(state => state.themeReducer.mode); const dispatch = useAppDispatch(); const currentSession = useAuthService().getCurrentSession(); const renderLoggedByTimer = () => { if (!logged_by_timer) return null; return ( - <> - via Timer about{' '} - - {logged_by_timer} - - + } color="green" style={{ fontSize: '11px', margin: 0 }}> + Timer + ); }; @@ -42,6 +42,9 @@ const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => { if (!logId || !selectedTaskId) return; const res = await taskTimeLogsApiService.delete(logId, selectedTaskId); if (res.done) { + // Trigger refresh of finance data + dispatch(setRefreshTimestamp()); + if (onDelete) onDelete(); } }; @@ -60,14 +63,14 @@ const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => { return ( - handleDeleteTimeLog(log.id)} > - @@ -75,33 +78,136 @@ const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => { ); }; + // Check if this time log is from a subtask + const isFromSubtask = task_id && task_id !== selectedTaskId; + + const formatTime = (timeString: string | undefined) => { + if (!timeString) return ''; + try { + return new Date(timeString).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + hour12: true + }); + } catch { + return timeString; + } + }; + + const formatDate = (timeString: string | undefined) => { + if (!timeString) return ''; + try { + return new Date(timeString).toLocaleDateString([], { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + } catch { + return timeString; + } + }; + + const isDarkMode = themeMode === 'dark'; + return (
- - - - - - - - {user_name} logged {time_spent_text} {renderLoggedByTimer()} {calculateTimeGap(created_at || '')} + + + {/* Header with user info and task name */} + + + + + + + {user_name} + + {task_name && ( + + {task_name} + + )} + {renderLoggedByTimer()} + + + {calculateTimeGap(created_at || '')} + + + + {renderActionButtons()} + + + {/* Time tracking details */} + + + + + Start Time + + + {formatTime(start_time)} - - {formatDateTimeWithLocale(created_at || '')} + + + + + + + End Time + + + {formatTime(end_time)} - {renderActionButtons()} + + + + + + + + Duration + + + {time_spent_text} + + + - {description && ( - + + {formatDate(created_at)} + + + + {/* Description */} + {description && ( + + + Description: + + {description} - )} - + + )} - - +
); }; diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer.tsx index bbec5479b..a65ba0c50 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer.tsx @@ -1,4 +1,4 @@ -import { TabsProps, Tabs, Button } from 'antd'; +import { TabsProps, Tabs, Button, Tooltip } from 'antd'; import Drawer from 'antd/es/drawer'; import { InputRef } from 'antd/es/input'; import { useTranslation } from 'react-i18next'; @@ -146,16 +146,40 @@ const TaskDrawer = () => { /> ); } else { + // Check if task has subtasks + const hasSubTasks = (taskFormViewModel?.task?.sub_tasks_count || 0) > 0; + const addTimeLogTooltip = hasSubTasks + ? t('taskTimeLogTab.timeLogDisabledTooltip', { + count: taskFormViewModel?.task?.sub_tasks_count || 0, + defaultValue: `Time logging is disabled because this task has ${taskFormViewModel?.task?.sub_tasks_count || 0} subtasks. Time should be logged on individual subtasks.` + }) + : ''; + + const addButton = ( + + ); + return ( - + {hasSubTasks ? ( + + {addButton} + + ) : ( + addButton + )} ); } diff --git a/worklenz-frontend/src/components/task-list-common/task-row/task-row-time-tracking/task-row-time-tracking.tsx b/worklenz-frontend/src/components/task-list-common/task-row/task-row-time-tracking/task-row-time-tracking.tsx index 2d4f73953..40173be50 100644 --- a/worklenz-frontend/src/components/task-list-common/task-row/task-row-time-tracking/task-row-time-tracking.tsx +++ b/worklenz-frontend/src/components/task-list-common/task-row/task-row-time-tracking/task-row-time-tracking.tsx @@ -3,7 +3,7 @@ import { Divider, Empty, Flex, Popover, Typography } from 'antd'; import { PlayCircleFilled } from '@ant-design/icons'; import { colors } from '@/styles/colors'; import CustomAvatar from '@components/CustomAvatar'; -import { mockTimeLogs } from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/mockTimeLogs'; +import { mockTimeLogs } from '@/shared/mockTimeLogs'; type TaskListTimeTrackerCellProps = { taskId: string | null; diff --git a/worklenz-frontend/src/components/taskListCommon/task-timer/task-timer.tsx b/worklenz-frontend/src/components/taskListCommon/task-timer/task-timer.tsx index 3427ac3db..6e4bc98c3 100644 --- a/worklenz-frontend/src/components/taskListCommon/task-timer/task-timer.tsx +++ b/worklenz-frontend/src/components/taskListCommon/task-timer/task-timer.tsx @@ -7,7 +7,7 @@ import logger from '@/utils/errorLogger'; import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale'; import { formatDate } from '@/utils/timeUtils'; import { PlayCircleFilled } from '@ant-design/icons'; -import { Flex, Button, Popover, Typography, Divider, Skeleton } from 'antd/es'; +import { Flex, Button, Popover, Typography, Divider, Skeleton, Tooltip, Tag } from 'antd/es'; import React from 'react'; import { useState } from 'react'; @@ -17,6 +17,8 @@ interface TaskTimerProps { handleStopTimer: () => void; timeString: string; taskId: string; + disabled?: boolean; + disabledTooltip?: string; } const TaskTimer = ({ @@ -25,6 +27,8 @@ const TaskTimer = ({ handleStopTimer, timeString, taskId, + disabled = false, + disabledTooltip, }: TaskTimerProps) => { const [timeLogs, setTimeLogs] = useState([]); const [loading, setLoading] = useState(false); @@ -69,32 +73,90 @@ const TaskTimer = ({ }; const timeTrackingLogCard = ( - + - {timeLogs.map(log => ( - - - - - - - {log.user_name}  - - logged  - - {formatTimeSpent(log.time_spent || 0)} - {' '} - {renderLoggedByTimer(log)} - {calculateTimeGap(log.created_at || '')} - - - {formatDateTimeWithLocale(log.created_at || '')} - + {timeLogs.map(log => { + // Check if this time log is from a subtask + const isFromSubtask = log.task_id && log.task_id !== taskId; + + const formatTime = (timeString: string | undefined) => { + if (!timeString) return ''; + try { + return new Date(timeString).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + hour12: true + }); + } catch { + return timeString; + } + }; + + return ( + + + + + + + + {log.user_name} + + {log.task_name && ( + + {log.task_name} + + )} + {log.logged_by_timer && ( + + Timer + + )} + + + {calculateTimeGap(log.created_at || '')} + + + + + + + + Start + + + {formatTime(log.start_time)} + + + + + + End + + + {formatTime(log.end_time)} + + + + + + Duration + + + {formatTimeSpent(log.time_spent || 0)} + + + - - - - ))} + + + ); + })} ); @@ -121,17 +183,45 @@ const TaskTimer = ({ } }; + const renderTimerButton = () => { + const button = started ? ( +
+ + + + + + + + + + + {taskBreakdown?.grouped_members?.map((group: any) => ( + + {/* Group Header */} + + + + + + + {/* Member Rows */} + {group.members?.map((member: any, index: number) => ( + + + + + + + ))} + + ))} + +
+ Role / Member + + Logged Hours + + Hourly Rate ({currency}) + + Actual Cost ({currency}) +
{group.jobRole} + {group.logged_hours?.toFixed(2) || '0.00'} + + - + + {group.actual_cost?.toFixed(2) || '0.00'} +
+ {member.name} + + {member.logged_hours?.toFixed(2) || '0.00'} + + {member.hourly_rate?.toFixed(2) || '0.00'} + + {member.actual_cost?.toFixed(2) || '0.00'} +
+ + )} + + + ); +}; + +export default FinanceDrawer; diff --git a/worklenz-frontend/src/features/finance/finance-slice.ts b/worklenz-frontend/src/features/finance/finance-slice.ts new file mode 100644 index 000000000..775f16bbb --- /dev/null +++ b/worklenz-frontend/src/features/finance/finance-slice.ts @@ -0,0 +1,200 @@ +import { rateCardApiService } from '@/api/settings/rate-cards/rate-cards.api.service'; +import { RatecardType } from '@/types/project/ratecard.types'; +import logger from '@/utils/errorLogger'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; + +type financeState = { + isRatecardDrawerOpen: boolean; + isFinanceDrawerOpen: boolean; + isImportRatecardsDrawerOpen: boolean; + currency: string; + isRatecardsLoading?: boolean; + isFinanceDrawerloading?: boolean; + drawerRatecard?: RatecardType | null; + ratecardsList?: RatecardType[] | null; + selectedTask?: any | null; +}; + +const initialState: financeState = { + isRatecardDrawerOpen: false, + isFinanceDrawerOpen: false, + isImportRatecardsDrawerOpen: false, + currency: 'USD', + isRatecardsLoading: false, + isFinanceDrawerloading: false, + drawerRatecard: null, + ratecardsList: null, + selectedTask: null, +}; +interface FetchRateCardsParams { + index: number; + size: number; + field: string | null; + order: string | null; + search: string | null; +} +// Async thunks +export const fetchRateCards = createAsyncThunk( + 'ratecards/fetchAll', + async (params: FetchRateCardsParams, { rejectWithValue }) => { + try { + const response = await rateCardApiService.getRateCards( + params.index, + params.size, + params.field, + params.order, + params.search + ); + return response.body; + } catch (error) { + logger.error('Fetch RateCards', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to fetch rate cards'); + } + } +); + +export const fetchRateCardById = createAsyncThunk( + 'ratecard/fetchById', + async (id: string, { rejectWithValue }) => { + try { + const response = await rateCardApiService.getRateCardById(id); + return response.body; + } catch (error) { + logger.error('Fetch RateCardById', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to fetch rate card'); + } + } +); + +export const createRateCard = createAsyncThunk( + 'ratecards/create', + async (body: RatecardType, { rejectWithValue }) => { + try { + const response = await rateCardApiService.createRateCard(body); + return response.body; + } catch (error) { + logger.error('Create RateCard', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to create rate card'); + } + } +); + +export const updateRateCard = createAsyncThunk( + 'ratecards/update', + async ({ id, body }: { id: string; body: RatecardType }, { rejectWithValue }) => { + try { + const response = await rateCardApiService.updateRateCard(id, body); + return response.body; + } catch (error) { + logger.error('Update RateCard', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to update rate card'); + } + } +); + +export const deleteRateCard = createAsyncThunk( + 'ratecards/delete', + async (id: string, { rejectWithValue }) => { + try { + await rateCardApiService.deleteRateCard(id); + return id; + } catch (error) { + logger.error('Delete RateCard', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to delete rate card'); + } + } +); + +const financeSlice = createSlice({ + name: 'financeReducer', + initialState, + reducers: { + toggleRatecardDrawer: (state) => { + state.isRatecardDrawerOpen = !state.isRatecardDrawerOpen; + }, + toggleFinanceDrawer: (state) => { + state.isFinanceDrawerOpen = !state.isFinanceDrawerOpen; + }, + openFinanceDrawer: (state, action: PayloadAction) => { + state.isFinanceDrawerOpen = true; + state.selectedTask = action.payload; + }, + closeFinanceDrawer: (state) => { + state.isFinanceDrawerOpen = false; + state.selectedTask = null; + }, + setSelectedTask: (state, action: PayloadAction) => { + state.selectedTask = action.payload; + }, + toggleImportRatecardsDrawer: (state) => { + state.isImportRatecardsDrawerOpen = !state.isImportRatecardsDrawerOpen; + }, + changeCurrency: (state, action: PayloadAction) => { + state.currency = action.payload; + }, + ratecardDrawerLoading: (state, action: PayloadAction) => { + state.isFinanceDrawerloading = action.payload; + }, + clearDrawerRatecard: (state) => { + state.drawerRatecard = null; + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchRateCards.pending, (state) => { + state.isRatecardsLoading = true; + }) + .addCase(fetchRateCards.fulfilled, (state, action) => { + state.isRatecardsLoading = false; + state.ratecardsList = Array.isArray(action.payload.data) + ? action.payload.data + : Array.isArray(action.payload) + ? action.payload + : []; + }) + .addCase(fetchRateCards.rejected, (state) => { + state.isRatecardsLoading = false; + state.ratecardsList = []; + }) + .addCase(fetchRateCardById.pending, (state) => { + state.isFinanceDrawerloading = true; + state.drawerRatecard = null; + }) + .addCase(fetchRateCardById.fulfilled, (state, action) => { + state.isFinanceDrawerloading = false; + state.drawerRatecard = action.payload; + }) + .addCase(fetchRateCardById.rejected, (state) => { + state.isFinanceDrawerloading = false; + state.drawerRatecard = null; + }); + }, +}); + +export const { + toggleRatecardDrawer, + toggleFinanceDrawer, + openFinanceDrawer, + closeFinanceDrawer, + setSelectedTask, + toggleImportRatecardsDrawer, + changeCurrency, + ratecardDrawerLoading, + clearDrawerRatecard, +} = financeSlice.actions; +export default financeSlice.reducer; diff --git a/worklenz-frontend/src/features/finance/project-finance-slice.ts b/worklenz-frontend/src/features/finance/project-finance-slice.ts new file mode 100644 index 000000000..c9549cfcd --- /dev/null +++ b/worklenz-frontend/src/features/finance/project-finance-slice.ts @@ -0,0 +1,271 @@ +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { projectRateCardApiService, IProjectRateCardRole } from '@/api/project-finance-ratecard/project-finance-rate-cards.api.service'; +import logger from '@/utils/errorLogger'; +import { JobRoleType } from '@/types/project/ratecard.types'; + +type ProjectFinanceRateCardState = { + isDrawerOpen: boolean; + isLoading: boolean; + rateCardRoles: JobRoleType[] | null; + drawerRole: IProjectRateCardRole | null; + error?: string | null; +}; + +const initialState: ProjectFinanceRateCardState = { + isDrawerOpen: false, + isLoading: false, + rateCardRoles: null, + drawerRole: null, + error: null, +}; + +// Async thunks +export const fetchProjectRateCardRoles = createAsyncThunk( + 'projectFinance/fetchAll', + async (project_id: string, { rejectWithValue }) => { + try { + const response = await projectRateCardApiService.getFromProjectId(project_id); + return response.body; + } catch (error) { + logger.error('Fetch Project RateCard Roles', error); + if (error instanceof Error) return rejectWithValue(error.message); + return rejectWithValue('Failed to fetch project rate card roles'); + } + } +); + +export const fetchProjectRateCardRoleById = createAsyncThunk( + 'projectFinance/fetchById', + async (id: string, { rejectWithValue }) => { + try { + const response = await projectRateCardApiService.getFromId(id); + return response.body; + } catch (error) { + logger.error('Fetch Project RateCard Role By Id', error); + if (error instanceof Error) return rejectWithValue(error.message); + return rejectWithValue('Failed to fetch project rate card role'); + } + } +); + +export const insertProjectRateCardRoles = createAsyncThunk( + 'projectFinance/insertMany', + async ({ project_id, roles }: { project_id: string; roles: Omit[] }, { rejectWithValue }) => { + try { + const response = await projectRateCardApiService.insertMany(project_id, roles); + return response.body; + } catch (error) { + logger.error('Insert Project RateCard Roles', error); + if (error instanceof Error) return rejectWithValue(error.message); + return rejectWithValue('Failed to insert project rate card roles'); + } + } +); + +export const insertProjectRateCardRole = createAsyncThunk( + 'projectFinance/insertOne', + async ( + { project_id, job_title_id, rate }: { project_id: string; job_title_id: string; rate: number }, + { rejectWithValue } + ) => { + try { + const response = await projectRateCardApiService.insertOne({ project_id, job_title_id, rate }); + return response.body; + } catch (error) { + logger.error('Insert Project RateCard Role', error); + if (error instanceof Error) return rejectWithValue(error.message); + return rejectWithValue('Failed to insert project rate card role'); + } + } +); + +export const updateProjectRateCardRoleById = createAsyncThunk( + 'projectFinance/updateById', + async ({ id, body }: { id: string; body: { job_title_id: string; rate: string } }, { rejectWithValue }) => { + try { + const response = await projectRateCardApiService.updateFromId(id, body); + return response.body; + } catch (error) { + logger.error('Update Project RateCard Role By Id', error); + if (error instanceof Error) return rejectWithValue(error.message); + return rejectWithValue('Failed to update project rate card role'); + } + } +); + +export const updateProjectRateCardRolesByProjectId = createAsyncThunk( + 'projectFinance/updateByProjectId', + async ({ project_id, roles }: { project_id: string; roles: Omit[] }, { rejectWithValue }) => { + try { + const response = await projectRateCardApiService.updateFromProjectId(project_id, roles); + return response.body; + } catch (error) { + logger.error('Update Project RateCard Roles By ProjectId', error); + if (error instanceof Error) return rejectWithValue(error.message); + return rejectWithValue('Failed to update project rate card roles'); + } + } +); + +export const deleteProjectRateCardRoleById = createAsyncThunk( + 'projectFinance/deleteById', + async (id: string, { rejectWithValue }) => { + try { + const response = await projectRateCardApiService.deleteFromId(id); + return response.body; + } catch (error) { + logger.error('Delete Project RateCard Role By Id', error); + if (error instanceof Error) return rejectWithValue(error.message); + return rejectWithValue('Failed to delete project rate card role'); + } + } +); + +export const assignMemberToRateCardRole = createAsyncThunk( + 'projectFinance/assignMemberToRateCardRole', + async ({ project_id, member_id, project_rate_card_role_id }: { project_id: string; member_id: string; project_rate_card_role_id: string }) => { + const response = await projectRateCardApiService.updateMemberRateCardRole(project_id, member_id, project_rate_card_role_id); + return response.body; + } +); + +export const deleteProjectRateCardRolesByProjectId = createAsyncThunk( + 'projectFinance/deleteByProjectId', + async (project_id: string, { rejectWithValue }) => { + try { + const response = await projectRateCardApiService.deleteFromProjectId(project_id); + return response.body; + } catch (error) { + logger.error('Delete Project RateCard Roles By ProjectId', error); + if (error instanceof Error) return rejectWithValue(error.message); + return rejectWithValue('Failed to delete project rate card roles'); + } + } +); + +const projectFinanceSlice = createSlice({ + name: 'projectFinanceRateCard', + initialState, + reducers: { + toggleDrawer: (state) => { + state.isDrawerOpen = !state.isDrawerOpen; + }, + clearDrawerRole: (state) => { + state.drawerRole = null; + }, + clearError: (state) => { + state.error = null; + }, + }, + extraReducers: (builder) => { + builder + // Fetch all + .addCase(fetchProjectRateCardRoles.pending, (state) => { + state.isLoading = true; + state.error = null; + }) + .addCase(fetchProjectRateCardRoles.fulfilled, (state, action) => { + state.isLoading = false; + state.rateCardRoles = action.payload || []; + }) + .addCase(fetchProjectRateCardRoles.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + state.rateCardRoles = []; + }) + // Fetch by id + .addCase(fetchProjectRateCardRoleById.pending, (state) => { + state.isLoading = true; + state.drawerRole = null; + state.error = null; + }) + .addCase(fetchProjectRateCardRoleById.fulfilled, (state, action) => { + state.isLoading = false; + state.drawerRole = action.payload || null; + }) + .addCase(fetchProjectRateCardRoleById.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + state.drawerRole = null; + }) + // Insert many + .addCase(insertProjectRateCardRoles.pending, (state) => { + state.isLoading = true; + state.error = null; + }) + .addCase(insertProjectRateCardRoles.fulfilled, (state, action) => { + state.isLoading = false; + state.rateCardRoles = action.payload || []; + }) + .addCase(insertProjectRateCardRoles.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }) + // Update by id + .addCase(updateProjectRateCardRoleById.pending, (state) => { + state.isLoading = true; + state.error = null; + }) + .addCase(updateProjectRateCardRoleById.fulfilled, (state, action) => { + state.isLoading = false; + if (state.rateCardRoles && action.payload) { + state.rateCardRoles = state.rateCardRoles.map((role) => + role.id === action.payload.id ? action.payload : role + ); + } + }) + .addCase(updateProjectRateCardRoleById.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }) + // Update by project id + .addCase(updateProjectRateCardRolesByProjectId.pending, (state) => { + state.isLoading = true; + state.error = null; + }) + .addCase(updateProjectRateCardRolesByProjectId.fulfilled, (state, action) => { + state.isLoading = false; + state.rateCardRoles = action.payload || []; + }) + .addCase(updateProjectRateCardRolesByProjectId.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }) + // Delete by id + .addCase(deleteProjectRateCardRoleById.pending, (state) => { + state.isLoading = true; + state.error = null; + }) + .addCase(deleteProjectRateCardRoleById.fulfilled, (state, action) => { + state.isLoading = false; + if (state.rateCardRoles && action.payload) { + state.rateCardRoles = state.rateCardRoles.filter((role) => role.id !== action.payload.id); + } + }) + .addCase(deleteProjectRateCardRoleById.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }) + // Delete by project id + .addCase(deleteProjectRateCardRolesByProjectId.pending, (state) => { + state.isLoading = true; + state.error = null; + }) + .addCase(deleteProjectRateCardRolesByProjectId.fulfilled, (state) => { + state.isLoading = false; + state.rateCardRoles = []; + }) + .addCase(deleteProjectRateCardRolesByProjectId.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }); + }, +}); + +export const { + toggleDrawer, + clearDrawerRole, + clearError, +} = projectFinanceSlice.actions; + +export default projectFinanceSlice.reducer; \ No newline at end of file diff --git a/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx b/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx new file mode 100644 index 000000000..14a05a036 --- /dev/null +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx @@ -0,0 +1,175 @@ +import { Drawer, Typography, Button, Table, Menu, Flex, Spin, Alert } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAppSelector } from '../../../hooks/useAppSelector'; +import { useAppDispatch } from '../../../hooks/useAppDispatch'; +import { fetchRateCards, toggleImportRatecardsDrawer } from '../finance-slice'; +import { fetchRateCardById } from '../finance-slice'; +import { insertProjectRateCardRoles } from '../project-finance-slice'; +import { useParams } from 'react-router-dom'; + +const ImportRatecardsDrawer: React.FC = () => { + const dispatch = useAppDispatch(); + const { projectId } = useParams(); + const { t } = useTranslation('project-view-finance'); + + const drawerRatecard = useAppSelector( + (state) => state.financeReducer.drawerRatecard + ); + const ratecardsList = useAppSelector( + (state) => state.financeReducer.ratecardsList || [] + ); + const isDrawerOpen = useAppSelector( + (state) => state.financeReducer.isImportRatecardsDrawerOpen + ); + // Get project currency from project finances, fallback to finance reducer currency + const projectCurrency = useAppSelector((state) => state.projectFinances.project?.currency); + const fallbackCurrency = useAppSelector((state) => state.financeReducer.currency); + const currency = (projectCurrency || fallbackCurrency || 'USD').toUpperCase(); + + const rolesRedux = useAppSelector((state) => state.projectFinanceRateCard.rateCardRoles) || []; + + // Loading states + const isRatecardsLoading = useAppSelector( + (state) => state.financeReducer.isRatecardsLoading + ); + + const [selectedRatecardId, setSelectedRatecardId] = useState(null); + + useEffect(() => { + if (selectedRatecardId) { + dispatch(fetchRateCardById(selectedRatecardId)); + } + }, [selectedRatecardId, dispatch]); + + useEffect(() => { + if (isDrawerOpen) { + dispatch(fetchRateCards({ + index: 1, + size: 1000, + field: 'name', + order: 'asc', + search: '', + })); + } + }, [isDrawerOpen, dispatch]); + + useEffect(() => { + if (ratecardsList.length > 0 && !selectedRatecardId) { + setSelectedRatecardId(ratecardsList[0].id || null); + } + }, [ratecardsList, selectedRatecardId]); + + const columns = [ + { + title: t('jobTitleColumn'), + dataIndex: 'jobtitle', + render: (text: string) => ( + + {text} + + ), + }, + { + title: `${t('ratePerHourColumn')} (${currency})`, + dataIndex: 'rate', + render: (text: number) => {text}, + }, + ]; + + return ( + + {t('ratecardsPluralText')} + + } + footer={ +
+ {/* Alert message */} + {rolesRedux.length !== 0 ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ } + open={isDrawerOpen} + onClose={() => dispatch(toggleImportRatecardsDrawer())} + width={1000} + > + + {/* Sidebar menu with loading */} + + setSelectedRatecardId(key)} + > + {ratecardsList.map((ratecard) => ( + + {ratecard.name} + + ))} + + + + {/* Table for job roles with loading */} + record.job_title_id} + onRow={() => ({ + className: 'group', + style: { cursor: 'pointer' }, + })} + pagination={false} + loading={isRatecardsLoading} + /> + + + ); +}; + +export default ImportRatecardsDrawer; \ No newline at end of file diff --git a/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx new file mode 100644 index 000000000..e4163e0d9 --- /dev/null +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx @@ -0,0 +1,530 @@ +import { Drawer, Select, Typography, Flex, Button, Input, Table, Tooltip, Alert, Space, message, Popconfirm } from 'antd'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAppSelector } from '../../../hooks/useAppSelector'; +import { useAppDispatch } from '../../../hooks/useAppDispatch'; +import { deleteRateCard, fetchRateCardById, fetchRateCards, toggleRatecardDrawer, updateRateCard } from '../finance-slice'; +import { RatecardType, IJobType } from '@/types/project/ratecard.types'; +import { IJobTitlesViewModel } from '@/types/job.types'; +import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service'; +import { DeleteOutlined, ExclamationCircleFilled, PlusOutlined } from '@ant-design/icons'; +import { colors } from '@/styles/colors'; +import CreateJobTitlesDrawer from '@/features/settings/job/CreateJobTitlesDrawer'; +import { toggleCreateJobTitleDrawer } from '@/features/settings/job/jobSlice'; +import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from '@/shared/constants/currencies'; + +interface PaginationType { + current: number; + pageSize: number; + field: string; + order: string; + total: number; + pageSizeOptions: string[]; + size: 'small' | 'default'; +} + +const RatecardDrawer = ({ + type, + ratecardId, + onSaved, +}: { + type: 'create' | 'update'; + ratecardId: string; + onSaved?: () => void; +}) => { + const [ratecardsList, setRatecardsList] = useState([]); + const [roles, setRoles] = useState([]); + const [initialRoles, setInitialRoles] = useState([]); + const [initialName, setInitialName] = useState('Untitled Rate Card'); + const [initialCurrency, setInitialCurrency] = useState(DEFAULT_CURRENCY); + const [addingRowIndex, setAddingRowIndex] = useState(null); + const { t } = useTranslation('settings/ratecard-settings'); + const drawerLoading = useAppSelector(state => state.financeReducer.isFinanceDrawerloading); + const drawerRatecard = useAppSelector(state => state.financeReducer.drawerRatecard); + const isDrawerOpen = useAppSelector( + (state) => state.financeReducer.isRatecardDrawerOpen + ); + const dispatch = useAppDispatch(); + + const [isAddingRole, setIsAddingRole] = useState(false); + const [selectedJobTitleId, setSelectedJobTitleId] = useState(undefined); + const [searchQuery, setSearchQuery] = useState(''); + const [currency, setCurrency] = useState(DEFAULT_CURRENCY); + const [name, setName] = useState('Untitled Rate Card'); + const [jobTitles, setJobTitles] = useState({}); + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 10000, + field: 'name', + order: 'desc', + total: 0, + pageSizeOptions: ['5', '10', '15', '20', '50', '100'], + size: 'small', + }); + const [editingRowIndex, setEditingRowIndex] = useState(null); + const [showUnsavedAlert, setShowUnsavedAlert] = useState(false); + const [messageApi, contextHolder] = message.useMessage(); + const [isCreatingJobTitle, setIsCreatingJobTitle] = useState(false); + const [newJobTitleName, setNewJobTitleName] = useState(''); + // Detect changes + const hasChanges = useMemo(() => { + const rolesChanged = JSON.stringify(roles) !== JSON.stringify(initialRoles); + const nameChanged = name !== initialName; + const currencyChanged = currency !== initialCurrency; + return rolesChanged || nameChanged || currencyChanged; + }, [roles, name, currency, initialRoles, initialName, initialCurrency]); + + const getJobTitles = useMemo(() => { + return async () => { + const response = await jobTitlesApiService.getJobTitles( + pagination.current, + pagination.pageSize, + pagination.field, + pagination.order, + searchQuery + ); + if (response.done) { + setJobTitles(response.body); + setPagination(prev => ({ ...prev, total: response.body.total || 0 })); + } + }; + }, [pagination.current, pagination.pageSize, pagination.field, pagination.order, searchQuery]); + + useEffect(() => { + getJobTitles(); + }, []); + + const selectedRatecard = ratecardsList.find( + (ratecard) => ratecard.id === ratecardId + ); + + useEffect(() => { + if (type === 'update' && ratecardId) { + dispatch(fetchRateCardById(ratecardId)); + } + }, [type, ratecardId, dispatch]); + + useEffect(() => { + if (type === 'update' && drawerRatecard) { + setRoles(drawerRatecard.jobRolesList || []); + setInitialRoles(drawerRatecard.jobRolesList || []); + setName(drawerRatecard.name || ''); + setInitialName(drawerRatecard.name || ''); + setCurrency(drawerRatecard.currency || DEFAULT_CURRENCY); + setInitialCurrency(drawerRatecard.currency || DEFAULT_CURRENCY); + } + }, [drawerRatecard, type]); + + const handleAddAllRoles = () => { + if (!jobTitles.data) return; + const existingIds = new Set(roles.map(r => r.job_title_id)); + const newRoles = jobTitles.data + .filter(jt => jt.id && !existingIds.has(jt.id)) + .map(jt => ({ + jobtitle: jt.name, + rate_card_id: ratecardId, + job_title_id: jt.id!, + rate: 0, + })); + const mergedRoles = [...roles, ...newRoles].filter( + (role, idx, arr) => + arr.findIndex(r => r.job_title_id === role.job_title_id) === idx + ); + setRoles(mergedRoles); + }; + + const handleAddRole = () => { + if (Object.keys(jobTitles).length === 0) { + // Allow inline job title creation + setIsCreatingJobTitle(true); + } else { + // Add a new empty role to the table + const newRole = { + jobtitle: '', + rate_card_id: ratecardId, + job_title_id: '', + rate: 0, + }; + setRoles([...roles, newRole]); + setAddingRowIndex(roles.length); + setIsAddingRole(true); + } + }; + + const handleCreateJobTitle = async () => { + if (!newJobTitleName.trim()) { + messageApi.warning(t('jobTitleNameRequired') || 'Job title name is required'); + return; + } + + try { + // Create the job title using the API + const response = await jobTitlesApiService.createJobTitle({ + name: newJobTitleName.trim() + }); + + if (response.done) { + // Refresh job titles + await getJobTitles(); + + // Create a new role with the newly created job title + const newRole = { + jobtitle: newJobTitleName.trim(), + rate_card_id: ratecardId, + job_title_id: response.body.id, + rate: 0, + }; + setRoles([...roles, newRole]); + + // Reset creation state + setIsCreatingJobTitle(false); + setNewJobTitleName(''); + + messageApi.success(t('jobTitleCreatedSuccess') || 'Job title created successfully'); + } else { + messageApi.error(t('jobTitleCreateError') || 'Failed to create job title'); + } + } catch (error) { + console.error('Failed to create job title:', error); + messageApi.error(t('jobTitleCreateError') || 'Failed to create job title'); + } + }; + + const handleCancelJobTitleCreation = () => { + setIsCreatingJobTitle(false); + setNewJobTitleName(''); + }; + + const handleDeleteRole = (index: number) => { + const updatedRoles = [...roles]; + updatedRoles.splice(index, 1); + setRoles(updatedRoles); + }; + + const handleSelectJobTitle = (jobTitleId: string) => { + if (roles.some(role => role.job_title_id === jobTitleId)) { + setIsAddingRole(false); + setSelectedJobTitleId(undefined); + return; + } + const jobTitle = jobTitles.data?.find(jt => jt.id === jobTitleId); + if (jobTitle) { + const newRole = { + jobtitle: jobTitle.name, + rate_card_id: ratecardId, + job_title_id: jobTitleId, + rate: 0, + }; + setRoles([...roles, newRole]); + } + setIsAddingRole(false); + setSelectedJobTitleId(undefined); + }; + + const handleSave = async () => { + if (type === 'update' && ratecardId) { + try { + const filteredRoles = roles.filter(role => role.jobtitle && role.jobtitle.trim() !== ''); + await dispatch(updateRateCard({ + id: ratecardId, + body: { + name, + currency, + jobRolesList: filteredRoles, + }, + }) as any); + await dispatch(fetchRateCards({ + index: 1, + size: 10, + field: 'name', + order: 'desc', + search: '', + }) as any); + if (onSaved) onSaved(); + dispatch(toggleRatecardDrawer()); + // Reset initial states after save + setInitialRoles(filteredRoles); + setInitialName(name); + setInitialCurrency(currency); + setShowUnsavedAlert(false); + } catch (error) { + console.error('Failed to update rate card', error); + } finally { + setRoles([]); + setName('Untitled Rate Card'); + setCurrency(DEFAULT_CURRENCY); + setInitialRoles([]); + setInitialName('Untitled Rate Card'); + setInitialCurrency(DEFAULT_CURRENCY); + } + } + }; + + const columns = [ + { + title: t('jobTitleColumn'), + dataIndex: 'jobtitle', + render: (text: string, record: any, index: number) => { + if (index === addingRowIndex || index === editingRowIndex) { + return ( + + ); + } + return ( + + {record.jobtitle} + + ); + }, + }, + { + title: `${t('ratePerHourColumn')} (${currency})`, + dataIndex: 'rate', + align: 'right', + render: (text: number, record: any, index: number) => ( + { + const updatedRoles = roles.map((role, idx) => + idx === index ? { ...role, rate: parseInt(e.target.value, 10) || 0 } : role + ); + setRoles(updatedRoles); + }} + /> + ), + }, + { + title: t('actionsColumn') || 'Actions', + dataIndex: 'actions', + render: (_: any, __: any, index: number) => ( + } + okText={t('deleteConfirmationOk')} + cancelText={t('deleteConfirmationCancel')} + onConfirm={async () => { + handleDeleteRole(index); + }} + > + +
+ + {t('createNewJobTitle') || 'Create New Job Title'} + + + setNewJobTitleName(e.target.value)} + onPressEnter={handleCreateJobTitle} + autoFocus + style={{ width: 200 }} + /> + + + + + ) : ( + + + {Object.keys(jobTitles).length === 0 + ? t('noJobTitlesAvailable') + : t('noRolesAdded')} + + + ), + }} + /> + + + + + ); +}; + +export default RatecardDrawer; \ No newline at end of file diff --git a/worklenz-frontend/src/features/navbar/navRoutes.ts b/worklenz-frontend/src/features/navbar/navRoutes.ts index 5e7a9e8f3..466ffd284 100644 --- a/worklenz-frontend/src/features/navbar/navRoutes.ts +++ b/worklenz-frontend/src/features/navbar/navRoutes.ts @@ -1,7 +1,7 @@ export type NavRoutesType = { name: string; path: string; - adminOnly: boolean; + adminOnly?: boolean; freePlanFeature?: boolean; }; diff --git a/worklenz-frontend/src/features/project/project.slice.ts b/worklenz-frontend/src/features/project/project.slice.ts index b2799e15d..99d05dad8 100644 --- a/worklenz-frontend/src/features/project/project.slice.ts +++ b/worklenz-frontend/src/features/project/project.slice.ts @@ -27,7 +27,7 @@ interface TaskListState { error: string | null; importTaskTemplateDrawerOpen: boolean; createTaskTemplateDrawerOpen: boolean; - projectView: 'list' | 'kanban'; + projectView: 'list' | 'kanban' | 'gantt'; refreshTimestamp: string | null; } @@ -35,9 +35,9 @@ const initialState: TaskListState = { projectId: null, project: null, projectLoading: false, - activeMembers: [], columns: [], members: [], + activeMembers: [], labels: [], statuses: [], priorities: [], @@ -116,6 +116,11 @@ const projectSlice = createSlice({ state.project.phase_label = action.payload; } }, + updateProjectCurrency: (state, action: PayloadAction) => { + if (state.project) { + state.project.currency = action.payload; + } + }, addTask: ( state, action: PayloadAction<{ task: IProjectTask; groupId: string; insert?: boolean }> @@ -173,7 +178,7 @@ const projectSlice = createSlice({ setRefreshTimestamp: (state) => { state.refreshTimestamp = new Date().getTime().toString(); }, - setProjectView: (state, action: PayloadAction<'list' | 'kanban'>) => { + setProjectView: (state, action: PayloadAction<'list' | 'kanban' | 'gantt'>) => { state.projectView = action.payload; }, }, @@ -214,7 +219,8 @@ export const { setCreateTaskTemplateDrawerOpen, setProjectView, updatePhaseLabel, - setRefreshTimestamp + setRefreshTimestamp, + updateProjectCurrency } = projectSlice.actions; export default projectSlice.reducer; diff --git a/worklenz-frontend/src/features/projects/finance/README.md b/worklenz-frontend/src/features/projects/finance/README.md new file mode 100644 index 000000000..82b8cac47 --- /dev/null +++ b/worklenz-frontend/src/features/projects/finance/README.md @@ -0,0 +1,83 @@ +# Optimized Finance Calculation System + +## Overview + +This system provides efficient frontend recalculation of project finance data when fixed costs are updated, eliminating the need for API refetches and ensuring optimal performance even with deeply nested task hierarchies. + +## Key Features + +### 1. Hierarchical Recalculation +- When a nested subtask's fixed cost is updated, all parent tasks are automatically recalculated +- Parent task totals are aggregated from their subtasks to avoid double counting +- Calculations propagate up the entire task hierarchy efficiently + +### 2. Performance Optimizations +- **Memoization**: Task calculations are cached to avoid redundant computations +- **Smart Cache Management**: Cache entries expire automatically and are cleaned up periodically +- **Selective Updates**: Only tasks that have actually changed trigger recalculations + +### 3. Frontend-Only Updates +- No API refetches required for fixed cost updates +- Immediate UI responsiveness +- Reduced server load and network traffic + +## How It Works + +### Task Update Flow +1. User updates fixed cost in UI +2. `updateTaskFixedCostAsync` is dispatched +3. API call updates the backend +4. Redux reducer updates the task and triggers `recalculateTaskHierarchy` +5. All parent tasks are recalculated automatically +6. UI updates immediately with new values + +### Calculation Logic +```typescript +// For parent tasks with subtasks +parentTask.fixed_cost = sum(subtask.fixed_cost) +parentTask.total_budget = parentTask.estimated_cost + parentTask.fixed_cost +parentTask.variance = parentTask.total_actual - parentTask.total_budget + +// For leaf tasks +task.total_budget = task.estimated_cost + task.fixed_cost +task.variance = task.total_actual - task.total_budget +``` + +### Memoization Strategy +- Cache key includes all relevant financial fields +- Cache entries expire after 10 minutes +- Cache is cleared when fresh data is loaded from API +- Automatic cleanup prevents memory leaks + +## Usage Examples + +### Updating Fixed Cost +```typescript +// This will automatically recalculate all parent tasks +dispatch(updateTaskFixedCostAsync({ + taskId: 'subtask-123', + groupId: 'group-456', + fixedCost: 1500 +})); +``` + +### Budget Statistics +The budget statistics in the project overview are calculated efficiently: +- Avoids double counting in nested hierarchies +- Uses aggregated values from parent tasks +- Updates automatically when any task changes + +## Performance Benefits + +1. **Reduced API Calls**: No refetching required for fixed cost updates +2. **Faster UI Updates**: Immediate recalculation and display +3. **Memory Efficient**: Smart caching with automatic cleanup +4. **Scalable**: Handles deeply nested task hierarchies efficiently + +## Cache Management + +The system includes automatic cache management: +- Cache cleanup every 5 minutes +- Entries expire after 10 minutes +- Manual cache clearing when fresh data is loaded +- Memory-efficient with automatic garbage collection \ No newline at end of file diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts new file mode 100644 index 000000000..c76b5550c --- /dev/null +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -0,0 +1,482 @@ +import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { IProjectFinanceGroup, IProjectFinanceTask, IProjectRateCard, IProjectFinanceProject } from '@/types/project/project-finance.types'; +import { parseTimeToSeconds } from '@/utils/timeUtils'; + +type FinanceTabType = 'finance' | 'ratecard'; +type GroupTypes = 'status' | 'priority' | 'phases'; +type BillableFilterType = 'all' | 'billable' | 'non-billable'; + +interface ProjectFinanceState { + activeTab: FinanceTabType; + activeGroup: GroupTypes; + billableFilter: BillableFilterType; + loading: boolean; + taskGroups: IProjectFinanceGroup[]; + projectRateCards: IProjectRateCard[]; + project: IProjectFinanceProject | null; +} + +// Enhanced utility functions for efficient frontend calculations +const secondsToHours = (seconds: number) => seconds / 3600; + +const calculateTaskCosts = (task: IProjectFinanceTask) => { + const hours = secondsToHours(task.estimated_seconds || 0); + const timeLoggedHours = secondsToHours(task.total_time_logged_seconds || 0); + + const totalBudget = task.estimated_cost || 0; + // task.total_actual already includes actual_cost_from_logs + fixed_cost from backend + const totalActual = task.total_actual || 0; + const variance = totalActual - totalBudget; + + return { + hours, + timeLoggedHours, + totalBudget, + totalActual, + variance + }; +}; + +// Memoization cache for task calculations to improve performance +const taskCalculationCache = new Map(); + +// Cache cleanup interval (5 minutes) +const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; +const CACHE_MAX_AGE = 10 * 60 * 1000; // 10 minutes + +// Periodic cache cleanup +setInterval(() => { + const now = Date.now(); + Array.from(taskCalculationCache.entries()).forEach(([key, value]) => { + if (now - value.timestamp > CACHE_MAX_AGE) { + taskCalculationCache.delete(key); + } + }); +}, CACHE_CLEANUP_INTERVAL); + +// Generate cache key for task +const generateTaskCacheKey = (task: IProjectFinanceTask): string => { + return `${task.id}-${task.estimated_cost}-${task.fixed_cost}-${task.total_actual}-${task.estimated_seconds}-${task.total_time_logged_seconds}`; +}; + +// Check if task has changed significantly to warrant recalculation +const hasTaskChanged = (oldTask: IProjectFinanceTask, newTask: IProjectFinanceTask): boolean => { + return ( + oldTask.estimated_cost !== newTask.estimated_cost || + oldTask.fixed_cost !== newTask.fixed_cost || + oldTask.total_actual !== newTask.total_actual || + oldTask.estimated_seconds !== newTask.estimated_seconds || + oldTask.total_time_logged_seconds !== newTask.total_time_logged_seconds + ); +}; + +// Optimized recursive calculation for task hierarchy with memoization +const recalculateTaskHierarchy = (tasks: IProjectFinanceTask[]): IProjectFinanceTask[] => { + return tasks.map(task => { + // If task has loaded subtasks, recalculate from subtasks + if (task.sub_tasks && task.sub_tasks.length > 0) { + const updatedSubTasks = recalculateTaskHierarchy(task.sub_tasks); + + // Calculate totals from subtasks only (for time and costs from logs) + const subtaskTotals = updatedSubTasks.reduce((acc, subtask) => ({ + estimated_cost: acc.estimated_cost + (subtask.estimated_cost || 0), + fixed_cost: acc.fixed_cost + (subtask.fixed_cost || 0), + actual_cost_from_logs: acc.actual_cost_from_logs + (subtask.actual_cost_from_logs || 0), + estimated_seconds: acc.estimated_seconds + (subtask.estimated_seconds || 0), + total_time_logged_seconds: acc.total_time_logged_seconds + (subtask.total_time_logged_seconds || 0) + }), { + estimated_cost: 0, + fixed_cost: 0, + actual_cost_from_logs: 0, + estimated_seconds: 0, + total_time_logged_seconds: 0 + }); + + // For parent tasks with loaded subtasks: use ONLY the subtask totals + // The parent's original values were backend-aggregated, now we use frontend subtask aggregation + const totalFixedCost = subtaskTotals.fixed_cost; // Only subtask fixed costs + const totalEstimatedCost = subtaskTotals.estimated_cost; // Only subtask estimated costs + const totalActualCostFromLogs = subtaskTotals.actual_cost_from_logs; // Only subtask logged costs + const totalActual = totalActualCostFromLogs + totalFixedCost; + + // Update parent task with aggregated values + const updatedTask = { + ...task, + sub_tasks: updatedSubTasks, + estimated_cost: totalEstimatedCost, + fixed_cost: totalFixedCost, + actual_cost_from_logs: totalActualCostFromLogs, + total_actual: totalActual, + estimated_seconds: subtaskTotals.estimated_seconds, + total_time_logged_seconds: subtaskTotals.total_time_logged_seconds, + total_budget: totalEstimatedCost, + variance: totalActual - totalEstimatedCost + }; + + return updatedTask; + } + + // For parent tasks without loaded subtasks, trust backend-calculated values + if (task.sub_tasks_count > 0 && (!task.sub_tasks || task.sub_tasks.length === 0)) { + // Parent task with unloaded subtasks - backend has already calculated aggregated values + const { totalBudget, totalActual, variance } = calculateTaskCosts(task); + return { + ...task, + total_budget: totalBudget, + total_actual: totalActual, + variance: variance + }; + } + + // For leaf tasks, check cache first + const cacheKey = generateTaskCacheKey(task); + const cached = taskCalculationCache.get(cacheKey); + + if (cached && !hasTaskChanged(cached.task, task)) { + return { ...cached.result, ...task }; // Merge with current task to preserve other properties + } + + // For leaf tasks, just recalculate their own values + const { totalBudget, totalActual, variance } = calculateTaskCosts(task); + const updatedTask = { + ...task, + total_budget: totalBudget, + total_actual: totalActual, + variance: variance + }; + + // Cache the result only for leaf tasks + taskCalculationCache.set(cacheKey, { + task: { ...task }, + result: updatedTask, + timestamp: Date.now() + }); + + return updatedTask; + }); +}; + +// Optimized function to find and update a specific task, then recalculate hierarchy +const updateTaskAndRecalculateHierarchy = ( + tasks: IProjectFinanceTask[], + targetId: string, + updateFn: (task: IProjectFinanceTask) => IProjectFinanceTask +): { updated: boolean; tasks: IProjectFinanceTask[] } => { + let updated = false; + + const updatedTasks = tasks.map(task => { + if (task.id === targetId) { + updated = true; + return updateFn(task); + } + + // Search in subtasks recursively + if (task.sub_tasks && task.sub_tasks.length > 0) { + const result = updateTaskAndRecalculateHierarchy(task.sub_tasks, targetId, updateFn); + if (result.updated) { + updated = true; + return { + ...task, + sub_tasks: result.tasks + }; + } + } + + return task; + }); + + // If a task was updated, recalculate the entire hierarchy to ensure parent totals are correct + return { + updated, + tasks: updated ? recalculateTaskHierarchy(updatedTasks) : updatedTasks + }; +}; + +const initialState: ProjectFinanceState = { + activeTab: 'finance', + activeGroup: 'status', + billableFilter: 'billable', + loading: false, + taskGroups: [], + projectRateCards: [], + project: null, +}; + +export const fetchProjectFinances = createAsyncThunk( + 'projectFinances/fetchProjectFinances', + async ({ projectId, groupBy, billableFilter }: { projectId: string; groupBy: GroupTypes; billableFilter?: BillableFilterType }) => { + const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy, billableFilter); + return response.body; + } +); + +export const fetchProjectFinancesSilent = createAsyncThunk( + 'projectFinances/fetchProjectFinancesSilent', + async ({ projectId, groupBy, billableFilter }: { projectId: string; groupBy: GroupTypes; billableFilter?: BillableFilterType }) => { + const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy, billableFilter); + return response.body; + } +); + +export const fetchSubTasks = createAsyncThunk( + 'projectFinances/fetchSubTasks', + async ({ projectId, parentTaskId, billableFilter }: { projectId: string; parentTaskId: string; billableFilter?: BillableFilterType }) => { + const response = await projectFinanceApiService.getSubTasks(projectId, parentTaskId, billableFilter); + return { parentTaskId, subTasks: response.body }; + } +); + +export const updateTaskFixedCostAsync = createAsyncThunk( + 'projectFinances/updateTaskFixedCostAsync', + async ({ taskId, groupId, fixedCost }: { taskId: string; groupId: string; fixedCost: number }) => { + await projectFinanceApiService.updateTaskFixedCost(taskId, fixedCost); + return { taskId, groupId, fixedCost }; + } +); + +// Function to clear calculation cache (useful for testing or when data is refreshed) +const clearCalculationCache = () => { + taskCalculationCache.clear(); +}; + +export const projectFinancesSlice = createSlice({ + name: 'projectFinances', + initialState, + reducers: { + setActiveTab: (state, action: PayloadAction) => { + state.activeTab = action.payload; + }, + setActiveGroup: (state, action: PayloadAction) => { + state.activeGroup = action.payload; + }, + setBillableFilter: (state, action: PayloadAction) => { + state.billableFilter = action.payload; + }, + updateTaskFixedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; fixedCost: number }>) => { + const { taskId, groupId, fixedCost } = action.payload; + const group = state.taskGroups.find(g => g.group_id === groupId); + + if (group) { + const result = updateTaskAndRecalculateHierarchy( + group.tasks, + taskId, + (task) => ({ + ...task, + fixed_cost: fixedCost + }) + ); + + if (result.updated) { + group.tasks = result.tasks; + } + } + }, + updateTaskEstimatedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; estimatedCost: number }>) => { + const { taskId, groupId, estimatedCost } = action.payload; + const group = state.taskGroups.find(g => g.group_id === groupId); + + if (group) { + const result = updateTaskAndRecalculateHierarchy( + group.tasks, + taskId, + (task) => ({ + ...task, + estimated_cost: estimatedCost + }) + ); + + if (result.updated) { + group.tasks = result.tasks; + } + } + }, + updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLoggedSeconds: number; timeLoggedString: string; totalActual: number }>) => { + const { taskId, groupId, timeLoggedSeconds, timeLoggedString, totalActual } = action.payload; + const group = state.taskGroups.find(g => g.group_id === groupId); + + if (group) { + const result = updateTaskAndRecalculateHierarchy( + group.tasks, + taskId, + (task) => ({ + ...task, + total_time_logged_seconds: timeLoggedSeconds, + total_time_logged: timeLoggedString, + total_actual: totalActual + }) + ); + + if (result.updated) { + group.tasks = result.tasks; + } + } + }, + toggleTaskExpansion: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => { + const { taskId, groupId } = action.payload; + const group = state.taskGroups.find(g => g.group_id === groupId); + + if (group) { + // Recursive function to find and toggle a task in the hierarchy + const findAndToggleTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => { + for (const task of tasks) { + if (task.id === targetId) { + task.show_sub_tasks = !task.show_sub_tasks; + return true; + } + + // Search in subtasks recursively + if (task.sub_tasks && findAndToggleTask(task.sub_tasks, targetId)) { + return true; + } + } + return false; + }; + + findAndToggleTask(group.tasks, taskId); + } + }, + updateProjectFinanceCurrency: (state, action: PayloadAction) => { + if (state.project) { + state.project.currency = action.payload; + } + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchProjectFinances.pending, (state) => { + state.loading = true; + }) + .addCase(fetchProjectFinances.fulfilled, (state, action) => { + state.loading = false; + // Apply hierarchy recalculation to ensure parent tasks show correct aggregated values + const recalculatedGroups = action.payload.groups.map(group => ({ + ...group, + tasks: recalculateTaskHierarchy(group.tasks) + })); + state.taskGroups = recalculatedGroups; + state.projectRateCards = action.payload.project_rate_cards; + state.project = action.payload.project; + // Clear cache when fresh data is loaded + clearCalculationCache(); + }) + .addCase(fetchProjectFinances.rejected, (state) => { + state.loading = false; + }) + .addCase(fetchProjectFinancesSilent.fulfilled, (state, action) => { + // Helper function to preserve expansion state and sub_tasks during updates + const preserveExpansionState = (existingTasks: IProjectFinanceTask[], newTasks: IProjectFinanceTask[]): IProjectFinanceTask[] => { + return newTasks.map(newTask => { + const existingTask = existingTasks.find(t => t.id === newTask.id); + if (existingTask) { + // Preserve expansion state and subtasks + const updatedTask = { + ...newTask, + show_sub_tasks: existingTask.show_sub_tasks, + sub_tasks: existingTask.sub_tasks ? + preserveExpansionState(existingTask.sub_tasks, newTask.sub_tasks || []) : + newTask.sub_tasks + }; + return updatedTask; + } + return newTask; + }); + }; + + // Update groups while preserving expansion state and applying hierarchy recalculation + const updatedTaskGroups = action.payload.groups.map(newGroup => { + const existingGroup = state.taskGroups.find(g => g.group_id === newGroup.group_id); + if (existingGroup) { + const tasksWithExpansion = preserveExpansionState(existingGroup.tasks, newGroup.tasks); + return { + ...newGroup, + tasks: recalculateTaskHierarchy(tasksWithExpansion) + }; + } + return { + ...newGroup, + tasks: recalculateTaskHierarchy(newGroup.tasks) + }; + }); + + // Update data without changing loading state for silent refresh + state.taskGroups = updatedTaskGroups; + state.projectRateCards = action.payload.project_rate_cards; + state.project = action.payload.project; + // Clear cache when data is refreshed from backend + clearCalculationCache(); + }) + .addCase(updateTaskFixedCostAsync.fulfilled, (state, action) => { + const { taskId, groupId, fixedCost } = action.payload; + const group = state.taskGroups.find(g => g.group_id === groupId); + + if (group) { + // Update the specific task's fixed cost and recalculate the entire hierarchy + const result = updateTaskAndRecalculateHierarchy( + group.tasks, + taskId, + (task) => ({ + ...task, + fixed_cost: fixedCost + }) + ); + + if (result.updated) { + group.tasks = result.tasks; + clearCalculationCache(); + } + } + }) + .addCase(fetchSubTasks.fulfilled, (state, action) => { + const { parentTaskId, subTasks } = action.payload; + + // Recursive function to find and update a task in the hierarchy + const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => { + for (const task of tasks) { + if (task.id === targetId) { + // Found the parent task, add subtasks + task.sub_tasks = subTasks.map(subTask => ({ + ...subTask, + is_sub_task: true, + parent_task_id: targetId + })); + task.show_sub_tasks = true; + return true; + } + + // Search in subtasks recursively + if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) { + return true; + } + } + return false; + }; + + // Find the parent task in any group and add the subtasks + for (const group of state.taskGroups) { + if (findAndUpdateTask(group.tasks, parentTaskId)) { + // Recalculate the hierarchy after adding subtasks to ensure parent values are correct + group.tasks = recalculateTaskHierarchy(group.tasks); + break; + } + } + }); + }, +}); + +export const { + setActiveTab, + setActiveGroup, + setBillableFilter, + updateTaskFixedCost, + updateTaskEstimatedCost, + updateTaskTimeLogged, + toggleTaskExpansion, + updateProjectFinanceCurrency +} = projectFinancesSlice.actions; + +export default projectFinancesSlice.reducer; diff --git a/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts b/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts index 9518495bb..81ad17c92 100644 --- a/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts +++ b/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts @@ -23,6 +23,12 @@ interface ITimeReportsOverviewState { billable: boolean; nonBillable: boolean; }; + + members: any[]; + loadingMembers: boolean; + + utilization: any[]; + loadingUtilization: boolean; } const initialState: ITimeReportsOverviewState = { @@ -42,6 +48,15 @@ const initialState: ITimeReportsOverviewState = { billable: true, nonBillable: true, }, + members: [], + loadingMembers: false, + + utilization: [], + loadingUtilization: false, +}; + +const selectedMembers = (state: ITimeReportsOverviewState) => { + return state.members.filter(member => member.selected).map(member => member.id) as string[]; }; const selectedTeams = (state: ITimeReportsOverviewState) => { @@ -54,6 +69,59 @@ const selectedCategories = (state: ITimeReportsOverviewState) => { .map(category => category.id) as string[]; }; +const selectedUtilization = (state: ITimeReportsOverviewState) => { + return state.utilization + .filter(utilization => utilization.selected) + .map(utilization => utilization.id) as string[]; +}; + +const allUtilization = (state: ITimeReportsOverviewState) => { + return state.utilization; +}; + +export const fetchReportingUtilization = createAsyncThunk( + 'timeReportsOverview/fetchReportingUtilization', + async (_, { rejectWithValue }) => { + try { + const utilization = [ + { id: 'under', name: 'Under-utilized (Under 90%)', selected: true }, + { id: 'optimal', name: 'Optimal-utilized (90%-110%)', selected: true }, + { id: 'over', name: 'Over-utilized (Over 110%)', selected: true }, + ]; + return utilization; + } catch (error) { + let errorMessage = 'An error occurred while fetching utilization'; + if (error instanceof Error) { + errorMessage = error.message; + } + return rejectWithValue(errorMessage); + } + } +); + +export const fetchReportingMembers = createAsyncThunk( + 'timeReportsOverview/fetchReportingMembers', + async (_, { rejectWithValue, getState }) => { + const state = getState() as { timeReportsOverviewReducer: ITimeReportsOverviewState }; + const { timeReportsOverviewReducer } = state; + + try { + const res = await reportingApiService.getMembers(selectedMembers(timeReportsOverviewReducer)); + if (res.done) { + return res.body; + } else { + return rejectWithValue(res.message || 'Failed to fetch members'); + } + } catch (error) { + let errorMessage = 'An error occurred while fetching members'; + if (error instanceof Error) { + errorMessage = error.message; + } + return rejectWithValue(errorMessage); + } + } +); + export const fetchReportingTeams = createAsyncThunk( 'timeReportsOverview/fetchReportingTeams', async () => { @@ -141,6 +209,31 @@ const timeReportsOverviewSlice = createSlice({ setArchived: (state, action: PayloadAction) => { state.archived = action.payload; }, + setSelectOrDeselectMember: (state, action: PayloadAction<{ id: string; selected: boolean }>) => { + const member = state.members.find(member => member.id === action.payload.id); + if (member) { + member.selected = action.payload.selected; + } + }, + setSelectOrDeselectAllMembers: (state, action: PayloadAction) => { + state.members.forEach(member => { + member.selected = action.payload; + }); + }, + setSelectOrDeselectUtilization: ( + state, + action: PayloadAction<{ id: string; selected: boolean }> + ) => { + const utilization = state.utilization.find(u => u.id === action.payload.id); + if (utilization) { + utilization.selected = action.payload.selected; + } + }, + setSelectOrDeselectAllUtilization: (state, action: PayloadAction) => { + state.utilization.forEach(utilization => { + utilization.selected = action.payload; + }); + }, }, extraReducers: builder => { builder.addCase(fetchReportingTeams.fulfilled, (state, action) => { @@ -185,6 +278,37 @@ const timeReportsOverviewSlice = createSlice({ builder.addCase(fetchReportingProjects.rejected, state => { state.loadingProjects = false; }); + builder.addCase(fetchReportingMembers.fulfilled, (state, action) => { + const members = action.payload.members.map((member: any) => ({ + id: member.id, + name: member.name, + selected: true, + avatar_url: member.avatar_url, + email: member.email, + })); + state.members = members; + state.loadingMembers = false; + }); + + builder.addCase(fetchReportingMembers.pending, state => { + state.loadingMembers = true; + }); + + builder.addCase(fetchReportingMembers.rejected, (state, action) => { + state.loadingMembers = false; + console.error('Error fetching members:', action.payload); + }); + builder.addCase(fetchReportingUtilization.fulfilled, (state, action) => { + state.utilization = action.payload; + state.loadingUtilization = false; + }); + builder.addCase(fetchReportingUtilization.pending, state => { + state.loadingUtilization = true; + }); + builder.addCase(fetchReportingUtilization.rejected, (state, action) => { + state.loadingUtilization = false; + console.error('Error fetching utilization:', action.payload); + }); }, }); @@ -197,6 +321,10 @@ export const { setSelectOrDeselectProject, setSelectOrDeselectAllProjects, setSelectOrDeselectBillable, + setSelectOrDeselectMember, + setSelectOrDeselectAllMembers, + setSelectOrDeselectUtilization, + setSelectOrDeselectAllUtilization, setNoCategory, setArchived, } = timeReportsOverviewSlice.actions; diff --git a/worklenz-frontend/src/hooks/useIsomorphicLayoutEffect.ts b/worklenz-frontend/src/hooks/useIsomorphicLayoutEffect.ts index 206b00843..46bd8cdac 100644 --- a/worklenz-frontend/src/hooks/useIsomorphicLayoutEffect.ts +++ b/worklenz-frontend/src/hooks/useIsomorphicLayoutEffect.ts @@ -1,6 +1,25 @@ import { useLayoutEffect, useEffect } from 'react'; // Use useLayoutEffect in browser environments and useEffect in SSR environments -const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; +// with additional safety checks to ensure React hooks are available +const useIsomorphicLayoutEffect = (() => { + // Check if we're in a browser environment + if (typeof window === 'undefined') { + // Server-side: return useEffect (which won't execute anyway) + return useEffect; + } + + // Client-side: ensure React hooks are available + try { + if (useLayoutEffect && typeof useLayoutEffect === 'function') { + return useLayoutEffect; + } + } catch (error) { + console.warn('useLayoutEffect not available, falling back to useEffect:', error); + } + + // Fallback to useEffect if useLayoutEffect is not available + return useEffect; +})(); export default useIsomorphicLayoutEffect; \ No newline at end of file diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index 7c85ead69..6cc17eed3 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -49,8 +49,9 @@ export const useTaskSocketHandlers = () => { const { socket } = useSocket(); const currentSession = useAuthService().getCurrentSession(); - const { loadingAssignees, taskGroups } = useAppSelector((state: any) => state.taskReducer); - const { projectId } = useAppSelector((state: any) => state.projectReducer); + const loadingAssignees = useAppSelector((state: any) => state.taskReducer.loadingAssignees); + const taskGroups = useAppSelector((state: any) => state.taskReducer.taskGroups); + const projectId = useAppSelector((state: any) => state.projectReducer.projectId); // Memoize socket event handlers const handleAssigneesUpdate = useCallback( diff --git a/worklenz-frontend/src/index.tsx b/worklenz-frontend/src/index.tsx index cf2c161c5..3822bb975 100644 --- a/worklenz-frontend/src/index.tsx +++ b/worklenz-frontend/src/index.tsx @@ -1,3 +1,6 @@ +// Import React polyfill first to ensure React is available globally +import './utils/react-polyfill'; + import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; diff --git a/worklenz-frontend/src/layouts/MainLayout.tsx b/worklenz-frontend/src/layouts/MainLayout.tsx index 83a4f4c4d..514e6c69e 100644 --- a/worklenz-frontend/src/layouts/MainLayout.tsx +++ b/worklenz-frontend/src/layouts/MainLayout.tsx @@ -5,15 +5,19 @@ import { useAppSelector } from '../hooks/useAppSelector'; import { useMediaQuery } from 'react-responsive'; import { colors } from '../styles/colors'; import { verifyAuthentication } from '@/features/auth/authSlice'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useAppDispatch } from '@/hooks/useAppDispatch'; -import HubSpot from '@/components/HubSpot'; +import { useAuthService } from '@/hooks/useAuth'; const MainLayout = () => { const themeMode = useAppSelector(state => state.themeReducer.mode); const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' }); const dispatch = useAppDispatch(); const navigate = useNavigate(); + const currentSession = useAuthService().getCurrentSession(); + + // State for alert visibility + const [showAlert, setShowAlert] = useState(false); const verifyAuthStatus = async () => { const session = await dispatch(verifyAuthentication()).unwrap(); @@ -26,6 +30,8 @@ const MainLayout = () => { void verifyAuthStatus(); }, [dispatch, navigate]); + const alertHeight = showAlert ? 64 : 0; // Fixed height for license alert + const headerStyles = { zIndex: 999, position: 'fixed', @@ -34,11 +40,13 @@ const MainLayout = () => { alignItems: 'center', padding: 0, borderBottom: themeMode === 'dark' ? '1px solid #303030' : 'none', + top: alertHeight, // Push navbar down when alert is shown } as const; const contentStyles = { - paddingInline: isDesktop ? 64 : 24, + paddingInline: isDesktop ? 0 : 24, overflowX: 'hidden', + marginTop: alertHeight + 64, // Adjust top margin based on alert height + navbar height } as const; return ( diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index fc4b8e87b..09433aea7 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -1,10 +1,14 @@ -import React, { ReactNode } from 'react'; -import ProjectViewInsights from '@/pages/projects/projectView/insights/project-view-insights'; -import ProjectViewFiles from '@/pages/projects/projectView/files/project-view-files'; -import ProjectViewMembers from '@/pages/projects/projectView/members/project-view-members'; -import ProjectViewUpdates from '@/pages/projects/project-view-1/updates/project-view-updates'; -import ProjectViewTaskList from '@/pages/projects/projectView/taskList/project-view-task-list'; -import ProjectViewBoard from '@/pages/projects/projectView/board/project-view-board'; +import React, { ReactNode, lazy } from 'react'; + +// Lazy load all project view components for better code splitting +const ProjectViewTaskList = lazy(() => import('@/pages/projects/projectView/taskList/project-view-task-list')); +const ProjectViewBoard = lazy(() => import('@/pages/projects/projectView/board/project-view-board')); +const ProjectViewGantt = lazy(() => import('@/pages/projects/projectView/gantt/project-view-gantt')); +const ProjectViewInsights = lazy(() => import('@/pages/projects/projectView/insights/project-view-insights')); +const ProjectViewFiles = lazy(() => import('@/pages/projects/projectView/files/project-view-files')); +const ProjectViewMembers = lazy(() => import('@/pages/projects/projectView/members/project-view-members')); +const ProjectViewUpdates = lazy(() => import('@/pages/projects/projectView/updates/ProjectViewUpdates')); +const ProjectViewFinance = lazy(() => import('@/pages/projects/projectView/finance/project-view-finance')); // type of a tab items type TabItems = { @@ -33,15 +37,9 @@ export const tabItems: TabItems[] = [ }, // { // index: 2, - // key: 'workload', - // label: 'Workload', - // element: React.createElement(ProjectViewWorkload), - // }, - // { - // index: 3, - // key: 'roadmap', - // label: 'Roadmap', - // element: React.createElement(ProjectViewRoadmap), + // key: 'gantt', + // label: 'Gantt Chart', + // element: React.createElement(ProjectViewGantt), // }, { index: 4, @@ -67,4 +65,10 @@ export const tabItems: TabItems[] = [ label: 'Updates', element: React.createElement(ProjectViewUpdates), }, + { + index: 8, + key: 'finance', + label: 'Finance', + element: React.createElement(ProjectViewFinance), + }, ]; diff --git a/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts b/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts new file mode 100644 index 000000000..09fee17e1 --- /dev/null +++ b/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts @@ -0,0 +1,85 @@ +export enum FinanceTableColumnKeys { + TASK = 'task', + MEMBERS = 'members', + HOURS = 'hours', + TOTAL_TIME_LOGGED = 'total_time_logged', + ESTIMATED_COST = 'estimated_cost', + COST = 'cost', + FIXED_COST = 'fixedCost', + TOTAL_BUDGET = 'totalBudget', + TOTAL_ACTUAL = 'totalActual', + VARIANCE = 'variance', +} + +type FinanceTableColumnsType = { + key: FinanceTableColumnKeys; + name: string; + width: number; + type: 'string' | 'hours' | 'currency'; + render?: (value: any) => React.ReactNode; + }; + + // finance table columns + export const financeTableColumns: FinanceTableColumnsType[] = [ + { + key: FinanceTableColumnKeys.TASK, + name: 'taskColumn', + width: 240, + type: 'string', + }, + { + key: FinanceTableColumnKeys.MEMBERS, + name: 'membersColumn', + width: 160, + type: 'string', + }, + { + key: FinanceTableColumnKeys.HOURS, + name: 'hoursColumn', + width: 100, + type: 'hours', + }, + { + key: FinanceTableColumnKeys.TOTAL_TIME_LOGGED, + name: 'totalTimeLoggedColumn', + width: 120, + type: 'hours', + }, + { + key: FinanceTableColumnKeys.ESTIMATED_COST, + name: 'estimatedCostColumn', + width: 120, + type: 'currency', + }, + { + key: FinanceTableColumnKeys.COST, + name: 'costColumn', + width: 120, + type: 'currency', + }, + { + key: FinanceTableColumnKeys.FIXED_COST, + name: 'fixedCostColumn', + width: 120, + type: 'currency', + }, + { + key: FinanceTableColumnKeys.TOTAL_BUDGET, + name: 'totalBudgetedCostColumn', + width: 120, + type: 'currency', + }, + { + key: FinanceTableColumnKeys.TOTAL_ACTUAL, + name: 'totalActualCostColumn', + width: 120, + type: 'currency', + }, + { + key: FinanceTableColumnKeys.VARIANCE, + name: 'varianceColumn', + width: 120, + type: 'currency', + }, + ]; + \ No newline at end of file diff --git a/worklenz-frontend/src/lib/settings/settings-constants.ts b/worklenz-frontend/src/lib/settings/settings-constants.ts index 7f9beb2af..b26b4b883 100644 --- a/worklenz-frontend/src/lib/settings/settings-constants.ts +++ b/worklenz-frontend/src/lib/settings/settings-constants.ts @@ -1,5 +1,6 @@ import { BankOutlined, + DollarCircleOutlined, FileZipOutlined, GlobalOutlined, GroupOutlined, @@ -26,6 +27,7 @@ import TeamMembersSettings from '@/pages/settings/team-members/team-members-sett import TeamsSettings from '../../pages/settings/teams/teams-settings'; import ChangePassword from '@/pages/settings/change-password/change-password'; import LanguageAndRegionSettings from '@/pages/settings/language-and-region/language-and-region-settings'; +import RatecardSettings from '@/pages/settings/rate-card/rate-card-settings'; import AppearanceSettings from '@/pages/settings/appearance/appearance-settings'; // type of menu item in settings sidebar @@ -132,6 +134,13 @@ export const settingsItems: SettingMenuItems[] = [ element: React.createElement(TeamMembersSettings), adminOnly: true, }, + { + key: 'ratecard', + name: 'Rate Card', + endpoint: 'ratecard', + icon: React.createElement(DollarCircleOutlined), + element: React.createElement(RatecardSettings), + }, { key: 'teams', name: 'teams', diff --git a/worklenz-frontend/src/pages/admin-center/overview/overview.tsx b/worklenz-frontend/src/pages/admin-center/overview/overview.tsx index e20d103c2..9af2137a4 100644 --- a/worklenz-frontend/src/pages/admin-center/overview/overview.tsx +++ b/worklenz-frontend/src/pages/admin-center/overview/overview.tsx @@ -1,6 +1,6 @@ import { EditOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons'; import { PageHeader } from '@ant-design/pro-components'; -import { Button, Card, Input, Space, Tooltip, Typography } from 'antd'; +import { Button, Card, Input, Space, Tooltip, Typography, Checkbox, Col, Form, Row, message } from 'antd'; import React, { useEffect, useState } from 'react'; import OrganizationAdminsTable from '@/components/admin-center/overview/organization-admins-table/organization-admins-table'; import { useAppSelector } from '@/hooks/useAppSelector'; @@ -12,6 +12,8 @@ import { adminCenterApiService } from '@/api/admin-center/admin-center.api.servi import { IOrganization, IOrganizationAdmin } from '@/types/admin-center/admin-center.types'; import logger from '@/utils/errorLogger'; import { tr } from 'date-fns/locale'; +import { scheduleAPIService } from '@/api/schedule/schedule.api.service'; +import { Settings } from '@/types/schedule/schedule-v2.types'; const { Text } = Typography; @@ -19,6 +21,10 @@ const Overview: React.FC = () => { const [organization, setOrganization] = useState(null); const [organizationAdmins, setOrganizationAdmins] = useState(null); const [loadingAdmins, setLoadingAdmins] = useState(false); + const [workingDays, setWorkingDays] = useState([]); + const [workingHours, setWorkingHours] = useState(8); + const [saving, setSaving] = useState(false); + const [form] = Form.useForm(); const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode); const { t } = useTranslation('admin-center/overview'); @@ -34,6 +40,19 @@ const Overview: React.FC = () => { } }; + const getOrgWorkingSettings = async () => { + try { + const res = await scheduleAPIService.fetchScheduleSettings(); + if (res && res.done) { + setWorkingDays(res.body.workingDays || ['Monday','Tuesday','Wednesday','Thursday','Friday']); + setWorkingHours(res.body.workingHours || 8); + form.setFieldsValue({ workingDays: res.body.workingDays || ['Monday','Tuesday','Wednesday','Thursday','Friday'], workingHours: res.body.workingHours || 8 }); + } + } catch (error) { + logger.error('Error getting organization working settings', error); + } + }; + const getOrganizationAdmins = async () => { setLoadingAdmins(true); try { @@ -48,8 +67,30 @@ const Overview: React.FC = () => { } }; + const handleSave = async (values: any) => { + setSaving(true); + try { + const res = await scheduleAPIService.updateScheduleSettings({ + workingDays: values.workingDays, + workingHours: values.workingHours, + }); + if (res && res.done) { + message.success(t('saved')); + setWorkingDays(values.workingDays); + setWorkingHours(values.workingHours); + getOrgWorkingSettings(); + } + } catch (error) { + logger.error('Error updating organization working days/hours', error); + message.error(t('errorSaving')); + } finally { + setSaving(false); + } + }; + useEffect(() => { getOrganizationDetails(); + getOrgWorkingSettings(); getOrganizationAdmins(); }, []); @@ -72,6 +113,37 @@ const Overview: React.FC = () => { refetch={getOrganizationDetails} /> + + {t('organizationWorkingDaysAndHours') || 'Organization Working Days & Hours'} +
+ + + +
{t('monday')} + {t('tuesday')} + {t('wednesday')} + {t('thursday')} + {t('friday')} + {t('saturday')} + {t('sunday')} + + + + + + + + + + + + {t('admins')} diff --git a/worklenz-frontend/src/pages/projects/project-view-1/board/card.tsx b/worklenz-frontend/src/pages/projects/project-view-1/board/card.tsx deleted file mode 100644 index b8019bb42..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/board/card.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { FC } from 'react'; -import { CSS } from '@dnd-kit/utilities'; -import { useSortable } from '@dnd-kit/sortable'; -export type CardType = { - id: string; - title: string; -}; - -const Card: FC = ({ id, title }) => { - // useSortableに指定するidは一意になるよう設定する必要があります。s - const { attributes, listeners, setNodeRef, transform } = useSortable({ - id: id, - }); - - const style = { - margin: '10px', - opacity: 1, - color: '#333', - background: 'white', - padding: '10px', - transform: CSS.Transform.toString(transform), - }; - - return ( - // attributes、listenersはDOMイベントを検知するために利用します。 - // listenersを任意の領域に付与することで、ドラッグするためのハンドルを作ることもできます。 -
-
-

{title}

-
-
- ); -}; - -export default Card; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/board/column.tsx b/worklenz-frontend/src/pages/projects/project-view-1/board/column.tsx deleted file mode 100644 index bacf2d2a8..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/board/column.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { FC } from 'react'; -import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable'; -import { useDroppable } from '@dnd-kit/core'; -import Card, { CardType } from './card'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; - -export type ColumnType = { - id: string; - title: string; - cards: IProjectTask[]; -}; - -const Column: FC = ({ id, title, cards }) => { - const { setNodeRef } = useDroppable({ id: id }); - return ( - // ソートを行うためのContextです。 - // strategyは4つほど存在しますが、今回は縦・横移動可能なリストを作るためrectSortingStrategyを採用 - -
-

- {title} -

- {cards.map(card => ( - - ))} -
-
- ); -}; - -export default Column; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/board/project-view-board.tsx b/worklenz-frontend/src/pages/projects/project-view-1/board/project-view-board.tsx deleted file mode 100644 index b9d407e48..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/board/project-view-board.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import React, { useEffect } from 'react'; -import { - DndContext, - DragEndEvent, - DragOverEvent, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core'; - -import { useAppSelector } from '@/hooks/useAppSelector'; -import TaskListFilters from '../taskList/taskListFilters/TaskListFilters'; -import { Button, Skeleton } from 'antd'; -import { PlusOutlined } from '@ant-design/icons'; -import { useDispatch } from 'react-redux'; -import { toggleDrawer } from '@/features/projects/status/StatusSlice'; -import KanbanGroup from '@/components/board/kanban-group/kanban-group'; - -const ProjectViewBoard: React.FC = () => { - const dispatch = useDispatch(); - - const { taskGroups, loadingGroups } = useAppSelector(state => state.taskReducer); - const { statusCategories } = useAppSelector(state => state.taskStatusReducer); - const groupBy = useAppSelector(state => state.groupByFilterDropdownReducer.groupBy); - const projectId = useAppSelector(state => state.projectReducer.projectId); - - useEffect(() => { - console.log('projectId', projectId); - // if (projectId) { - // const config: ITaskListConfigV2 = { - // id: projectId, - // field: 'id', - // order: 'desc', - // search: '', - // statuses: '', - // members: '', - // projects: '', - // isSubtasksInclude: false, - // }; - // dispatch(fetchTaskGroups(config) as any); - // } - // if (!statusCategories.length) { - // dispatch(fetchStatusesCategories() as any); - // } - }, [dispatch, projectId, groupBy]); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }) - ); - - const handleDragOver = (event: DragOverEvent) => { - const { active, over } = event; - if (!over) return; - - const activeTask = active.data.current?.task; - const overId = over.id; - - // Find which group the task is being dragged over - const targetGroup = taskGroups.find( - group => group.id === overId || group.tasks.some(task => task.id === overId) - ); - - if (targetGroup && activeTask) { - // Here you would dispatch an action to update the task's status - // For example: - // dispatch(updateTaskStatus({ taskId: activeTask.id, newStatus: targetGroup.id })); - console.log('Moving task', activeTask.id, 'to group', targetGroup.id); - } - }; - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - if (!over) return; - - const activeTask = active.data.current?.task; - const overId = over.id; - - // Similar to handleDragOver, but this is where you'd make the final update - const targetGroup = taskGroups.find( - group => group.id === overId || group.tasks.some(task => task.id === overId) - ); - - if (targetGroup && activeTask) { - // Make the final update to your backend/state - console.log('Final move of task', activeTask.id, 'to group', targetGroup.id); - } - }; - - return ( -
- - - -
- -
- {taskGroups.map(group => ( - - ))} - -
-
-
-
-
- ); -}; - -export default ProjectViewBoard; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/project-view-roadmap.css b/worklenz-frontend/src/pages/projects/project-view-1/roadmap/project-view-roadmap.css deleted file mode 100644 index 6db276089..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/project-view-roadmap.css +++ /dev/null @@ -1,116 +0,0 @@ -:root { - --odd-row-color: #fff; - --even-row-color: #4e4e4e10; - --text-color: #181818; - --border: 1px solid #e0e0e0; - --stroke: #e0e0e0; - - --calender-header-bg: #fafafa; -} - -.dark-theme { - --odd-row-color: #141414; - --even-row-color: #4e4e4e10; - --text-color: #fff; - --border: 1px solid #505050; - --stroke: #505050; - - --calender-header-bg: #1d1d1d; -} - -/* scroll bar size override */ -._2k9Ys { - scrollbar-width: unset; -} - -/* ----------------------------------------------------------------------- */ -/* task details side even rows */ -._34SS0:nth-of-type(even) { - background-color: var(--even-row-color); -} - -/* task details side header and body */ -._3_ygE { - border-top: var(--border); - border-left: var(--border); - position: relative; -} -._2B2zv { - border-bottom: var(--border); - border-left: var(--border); - position: relative; -} - -._3ZbQT { - border: none; -} - -._3_ygE::after, -._2B2zv::after { - content: ""; - position: absolute; - top: 0; - right: -25px; - width: 30px; - height: 100%; - box-shadow: inset 10px 0 8px -8px #00000026; -} - -/* ._3lLk3:nth-child(1), -._WuQ0f:nth-child(1) { - min-width: 300px !important; - max-width: 300px !important; -} - -._2eZzQ, -._WuQ0f:nth-child(3), -._WuQ0f:last-child, -._3lLk3:nth-child(2), -._3lLk3:nth-child(3) { - display: none; -} */ - -/* ----------------------------------------------------------------------- */ -/* calender side header */ -._35nLX { - fill: var(--calender-header-bg); - stroke: var(--stroke); - stroke-width: 1px; -} - -/* calender side header texts */ -._9w8d5, -._2q1Kt { - fill: var(--text-color); -} - -/* calender side odd rows */ -._2dZTy:nth-child(odd) { - fill: var(--odd-row-color); -} -/* calender side even rows */ -._2dZTy:nth-child(even) { - fill: var(--even-row-color); -} - -/* calender side body row lines */ -._3rUKi { - stroke: var(--stroke); - stroke-width: 0.3px; -} - -/* calender side body ticks */ -._RuwuK { - stroke: var(--stroke); - stroke-width: 0.3px; -} - -/* calender side header ticks */ -._1rLuZ { - stroke: var(--stroke); - stroke-width: 1px; -} - -.roadmap-table .ant-table-thead .ant-table-cell { - height: 50px; -} diff --git a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/project-view-roadmap.tsx b/worklenz-frontend/src/pages/projects/project-view-1/roadmap/project-view-roadmap.tsx deleted file mode 100644 index a5e7d828a..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/project-view-roadmap.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useState } from 'react'; -import { ViewMode } from 'gantt-task-react'; -import 'gantt-task-react/dist/index.css'; -import './project-view-roadmap.css'; -import { Flex } from 'antd'; -import { useAppSelector } from '../../../../hooks/useAppSelector'; -import { TimeFilter } from './time-filter'; -import RoadmapTable from './roadmap-table/roadmap-table'; -import RoadmapGrantChart from './roadmap-grant-chart'; - -const ProjectViewRoadmap = () => { - const [view, setView] = useState(ViewMode.Day); - - // get theme details - const themeMode = useAppSelector(state => state.themeReducer.mode); - - return ( - - {/* time filter */} - setView(viewMode)} /> - - - {/* table */} -
- -
- - {/* gantt Chart */} - -
-
- ); -}; - -export default ProjectViewRoadmap; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-grant-chart.tsx b/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-grant-chart.tsx deleted file mode 100644 index 067d07231..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-grant-chart.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Gantt, Task, ViewMode } from 'gantt-task-react'; -import React from 'react'; -import { colors } from '../../../../styles/colors'; -import { - NewTaskType, - updateTaskDate, - updateTaskProgress, -} from '../../../../features/roadmap/roadmap-slice'; -import { useAppSelector } from '../../../../hooks/useAppSelector'; -import { useAppDispatch } from '../../../../hooks/useAppDispatch'; -import { toggleTaskDrawer } from '../../../../features/tasks/tasks.slice'; - -type RoadmapGrantChartProps = { - view: ViewMode; -}; - -const RoadmapGrantChart = ({ view }: RoadmapGrantChartProps) => { - // get task list from roadmap slice - const tasks = useAppSelector(state => state.roadmapReducer.tasksList); - - const dispatch = useAppDispatch(); - - // column widths for each view mods - let columnWidth = 60; - if (view === ViewMode.Year) { - columnWidth = 350; - } else if (view === ViewMode.Month) { - columnWidth = 300; - } else if (view === ViewMode.Week) { - columnWidth = 250; - } - - // function to handle double click - const handleDoubleClick = () => { - dispatch(toggleTaskDrawer()); - }; - - // function to handle date change - const handleTaskDateChange = (task: Task) => { - dispatch(updateTaskDate({ taskId: task.id, start: task.start, end: task.end })); - }; - - // function to handle progress change - const handleTaskProgressChange = (task: Task) => { - dispatch(updateTaskProgress({ taskId: task.id, progress: task.progress })); - }; - - // function to convert the tasklist comming form roadmap slice which has NewTaskType converted to Task type which is the default type of the tasks list in the grant chart - const flattenTasks = (tasks: NewTaskType[]): Task[] => { - const flattened: Task[] = []; - - const addTaskAndSubTasks = (task: NewTaskType, parentExpanded: boolean) => { - // add the task to the flattened list if its parent is expanded or it is a top-level task - if (parentExpanded) { - const { subTasks, isExpanded, ...rest } = task; // destructure to exclude properties not in Task type - flattened.push(rest as Task); - - // recursively add subtasks if this task is expanded - if (subTasks && isExpanded) { - subTasks.forEach(subTask => addTaskAndSubTasks(subTask as NewTaskType, true)); - } - } - }; - - // top-level tasks are always visible, start with parentExpanded = true - tasks.forEach(task => addTaskAndSubTasks(task, true)); - - return flattened; - }; - - const flattenedTasksList = flattenTasks(tasks); - - return ( -
- -
- ); -}; - -export default RoadmapGrantChart; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-table/roadmap-table.tsx b/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-table/roadmap-table.tsx deleted file mode 100644 index ca3c6e774..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-table/roadmap-table.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import React from 'react'; -import { DatePicker, Typography } from 'antd'; -import dayjs, { Dayjs } from 'dayjs'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { NewTaskType, updateTaskDate } from '@features/roadmap/roadmap-slice'; -import { colors } from '@/styles/colors'; -import RoadmapTaskCell from './roadmap-task-cell'; - -const RoadmapTable = () => { - // Get task list and expanded tasks from roadmap slice - const tasks = useAppSelector(state => state.roadmapReducer.tasksList); - - // Get theme data from theme slice - const themeMode = useAppSelector(state => state.themeReducer.mode); - - const dispatch = useAppDispatch(); - - // function to handle date changes - const handleDateChange = (taskId: string, dateType: 'start' | 'end', date: Dayjs) => { - const updatedDate = date.toDate(); - - dispatch( - updateTaskDate({ - taskId, - start: dateType === 'start' ? updatedDate : new Date(), - end: dateType === 'end' ? updatedDate : new Date(), - }) - ); - }; - - // Adjusted column type with a string or ReactNode for the title - const columns: { key: string; title: React.ReactNode; width: number }[] = [ - { - key: 'name', - title: 'Task Name', - width: 240, - }, - { - key: 'start', - title: 'Start Date', - width: 130, - }, - { - key: 'end', - title: 'End Date', - width: 130, - }, - ]; - - // Function to render the column content based on column key - const renderColumnContent = ( - columnKey: string, - task: NewTaskType, - isSubtask: boolean = false - ) => { - switch (columnKey) { - case 'name': - return ; - case 'start': - const startDayjs = task.start ? dayjs(task.start) : null; - return ( - handleDateChange(task.id, 'end', date)} - style={{ - backgroundColor: colors.transparent, - border: 'none', - boxShadow: 'none', - }} - /> - ); - case 'end': - const endDayjs = task.end ? dayjs(task.end) : null; - return ( - handleDateChange(task.id, 'end', date)} - style={{ - backgroundColor: colors.transparent, - border: 'none', - boxShadow: 'none', - }} - /> - ); - - default: - return null; - } - }; - - const dataSource = tasks.map(task => ({ - id: task.id, - name: task.name, - start: task.start, - end: task.end, - type: task.type, - progress: task.progress, - subTasks: task.subTasks, - isExpanded: task.isExpanded, - })); - - // Layout styles for table and columns - const customHeaderColumnStyles = `border px-2 h-[50px] text-left z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`; - - const customBodyColumnStyles = `border px-2 h-[50px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent ${themeMode === 'dark' ? 'bg-transparent border-[#303030]' : 'bg-transparent'}`; - - const rowBackgroundStyles = - themeMode === 'dark' ? 'even:bg-[#1b1b1b] odd:bg-[#141414]' : 'even:bg-[#f4f4f4] odd:bg-white'; - - return ( -
-
- - - {/* table header */} - {columns.map(column => ( - - ))} - - - - {dataSource.length === 0 ? ( - - - - ) : ( - dataSource.map(task => ( - - - {columns.map(column => ( - - ))} - - - {/* subtasks */} - {task.isExpanded && - task?.subTasks?.map(subtask => ( - - {columns.map(column => ( - - ))} - - ))} - - )) - )} - -
- {column.title} -
- No tasks available -
- {renderColumnContent(column.key, task)} -
- {renderColumnContent(column.key, subtask, true)} -
- - ); -}; - -export default RoadmapTable; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-table/roadmap-task-cell.tsx b/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-table/roadmap-task-cell.tsx deleted file mode 100644 index 246026b86..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-table/roadmap-task-cell.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { Flex, Typography, Button, Tooltip } from 'antd'; -import { - DoubleRightOutlined, - DownOutlined, - RightOutlined, - ExpandAltOutlined, -} from '@ant-design/icons'; -import { NewTaskType, toggleTaskExpansion } from '@features/roadmap/roadmap-slice'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { toggleTaskDrawer } from '@features/tasks/taskSlice'; -import { colors } from '@/styles/colors'; - -type RoadmapTaskCellProps = { - task: NewTaskType; - isSubtask?: boolean; -}; - -const RoadmapTaskCell = ({ task, isSubtask = false }: RoadmapTaskCellProps) => { - const dispatch = useAppDispatch(); - - // render the toggle arrow icon for tasks with subtasks - const renderToggleButtonForHasSubTasks = (id: string, hasSubtasks: boolean) => { - if (!hasSubtasks) return null; - return ( - - ); - }; - - // show expand button on hover for tasks without subtasks - const renderToggleButtonForNonSubtasks = (id: string, isSubtask: boolean) => { - return !isSubtask ? ( - - ) : ( -
- ); - }; - - // render the double arrow icon and count label for tasks with subtasks - const renderSubtasksCountLabel = (id: string, isSubtask: boolean, subTasksCount: number) => { - return ( - !isSubtask && ( - - ) - ); - }; - - return ( - - - {!!task?.subTasks?.length ? ( - renderToggleButtonForHasSubTasks(task.id, !!task?.subTasks?.length) - ) : ( -
- {renderToggleButtonForNonSubtasks(task.id, isSubtask)} -
- )} - - {isSubtask && } - - - - {task.name} - - - - {renderSubtasksCountLabel(task.id, isSubtask, task?.subTasks?.length || 0)} -
- - -
- ); -}; - -export default RoadmapTaskCell; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/time-filter.tsx b/worklenz-frontend/src/pages/projects/project-view-1/roadmap/time-filter.tsx deleted file mode 100644 index ff1461f10..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/time-filter.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react'; -import 'gantt-task-react/dist/index.css'; -import { ViewMode } from 'gantt-task-react'; -import { Flex, Select } from 'antd'; -type TimeFilterProps = { - onViewModeChange: (viewMode: ViewMode) => void; -}; -export const TimeFilter = ({ onViewModeChange }: TimeFilterProps) => { - // function to handle time change - const handleChange = (value: string) => { - switch (value) { - case 'hour': - return onViewModeChange(ViewMode.Hour); - case 'quaterDay': - return onViewModeChange(ViewMode.QuarterDay); - case 'halfDay': - return onViewModeChange(ViewMode.HalfDay); - case 'day': - return onViewModeChange(ViewMode.Day); - case 'week': - return onViewModeChange(ViewMode.Week); - case 'month': - return onViewModeChange(ViewMode.Month); - case 'year': - return onViewModeChange(ViewMode.Year); - default: - return onViewModeChange(ViewMode.Day); - } - }; - - const timeFilterItems = [ - { - value: 'hour', - label: 'Hour', - }, - { - value: 'quaterDay', - label: 'Quater Day', - }, - { - value: 'halfDay', - label: 'Half Day', - }, - { - value: 'day', - label: 'Day', - }, - { - value: 'week', - label: 'Week', - }, - { - value: 'month', - label: 'Month', - }, - { - value: 'year', - label: 'Year', - }, - ]; - - return ( - - - - ), - }), - - // columnHelper.accessor('time_tracking', { - // header: 'Time Tracking', - // size: 120, - // enablePinning: false, - // cell: ({ row }) => ( - // - // ) - // }) - ]; -}; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.css b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.css deleted file mode 100644 index a47805009..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.css +++ /dev/null @@ -1,44 +0,0 @@ -.table-header { - border-bottom: 1px solid #d9d9d9; - /* Border below header */ -} - -.table-body { - background-color: #ffffff; - /* White background for body */ -} - -.table-row { - display: flex; - /* Use flexbox for row layout */ - align-items: center; - /* Center items vertically */ - transition: background-color 0.2s; - /* Smooth background transition */ -} - -.table-row:hover { - background-color: #f5f5f5; - /* Light gray background on hover */ -} - -/* Optional: Add styles for sticky headers */ -.table-header > div { - position: sticky; - /* Make header cells sticky */ - top: 0; - /* Stick to the top */ - z-index: 1; - /* Ensure it stays above other content */ -} - -/* Optional: Add styles for cell borders */ -.table-row > div { - border-right: 1px solid #d9d9d9; - /* Right border for cells */ -} - -.table-row > div:last-child { - border-right: none; - /* Remove right border for last cell */ -} diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.tsx deleted file mode 100644 index e2ca07f29..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; -import { Checkbox, theme } from 'antd'; -import { - useReactTable, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - flexRender, - VisibilityState, - Row, - Column, -} from '@tanstack/react-table'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import React from 'react'; -import './task-list-custom.css'; -import TaskListInstantTaskInput from './task-list-instant-task-input/task-list-instant-task-input'; -import { useAuthService } from '@/hooks/useAuth'; -import { createColumns } from './task-list-columns/task-list-columns'; - -interface TaskListCustomProps { - tasks: IProjectTask[]; - color: string; - groupId?: string | null; - onTaskSelect?: (taskId: string) => void; -} - -const TaskListCustom: React.FC = ({ tasks, color, groupId, onTaskSelect }) => { - const [rowSelection, setRowSelection] = useState({}); - const [columnVisibility, setColumnVisibility] = useState({}); - const [expandedRows, setExpandedRows] = useState>({}); - - const statuses = useAppSelector(state => state.taskStatusReducer.status); - const tableContainerRef = useRef(null); - const { token } = theme.useToken(); - const { getCurrentSession } = useAuthService(); - - const handleExpandClick = useCallback((rowId: string) => { - setExpandedRows(prev => ({ - ...prev, - [rowId]: !prev[rowId], - })); - }, []); - - const handleTaskSelect = useCallback( - (taskId: string) => { - onTaskSelect?.(taskId); - }, - [onTaskSelect] - ); - - const columns = useMemo( - () => - createColumns({ - expandedRows, - statuses, - handleTaskSelect, - getCurrentSession, - }), - [expandedRows, statuses, handleTaskSelect, getCurrentSession] - ); - - const table = useReactTable({ - data: tasks, - columns, - state: { - rowSelection, - columnVisibility, - }, - enableRowSelection: true, - onRowSelectionChange: setRowSelection, - onColumnVisibilityChange: setColumnVisibility, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - }); - - const { rows } = table.getRowModel(); - - const rowVirtualizer = useVirtualizer({ - count: rows.length, - getScrollElement: () => tableContainerRef.current, - estimateSize: () => 50, - overscan: 20, - }); - - const virtualRows = rowVirtualizer.getVirtualItems(); - const totalSize = rowVirtualizer.getTotalSize(); - const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0; - const paddingBottom = - virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0; - - const columnToggleItems = columns.map(column => ({ - key: column.id as string, - label: ( - - - {typeof column.header === 'string' ? column.header : column.id} - - - ), - onClick: () => { - const columnData = table.getColumn(column.id as string); - if (columnData) { - columnData.toggleVisibility(); - } - }, - })); - - return ( -
-
-
-
- {table.getHeaderGroups().map(headerGroup => ( -
- {headerGroup.headers.map((header, index) => ( -
- {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} -
- ))} -
- ))} -
-
- {paddingTop > 0 &&
} - {virtualRows.map(virtualRow => { - const row = rows[virtualRow.index]; - return ( - -
- {row.getVisibleCells().map((cell, index) => ( -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- ))} -
- {expandedRows[row.id] && - row.original.sub_tasks?.map(subTask => ( -
- {columns.map((col, index) => ( -
- {flexRender(col.cell, { - getValue: () => subTask[col.id as keyof typeof subTask] ?? null, - row: { original: subTask } as Row, - column: col as Column, - table, - })} -
- ))} -
- ))} -
- ); - })} - {paddingBottom > 0 &&
} -
-
-
- - {/* {selectedCount > 0 && ( - - {selectedCount} tasks selected - - - - - - )} */} -
- ); -}; - -export default TaskListCustom; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-header/task-list-header.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-header/task-list-header.tsx deleted file mode 100644 index 120438056..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-header/task-list-header.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React, { useState } from 'react'; -import { Button, Dropdown, Input, Menu, Badge, Tooltip } from 'antd'; -import { - RightOutlined, - LoadingOutlined, - EllipsisOutlined, - EditOutlined, - RetweetOutlined, -} from '@ant-design/icons'; -import { ITaskListGroup } from '@/types/tasks/taskList.types'; -import { ITaskStatusCategory } from '@/types/status.types'; -import { useAppSelector } from '@/hooks/useAppSelector'; -// import WorklenzTaskListPhaseDuration from "./WorklenzTaskListPhaseDuration"; -// import WorklenzTasksProgressBar from "./WorklenzTasksProgressBar"; - -interface Props { - group: ITaskListGroup; - projectId: string | null; - categories: ITaskStatusCategory[]; -} - -const TaskListGroupSettings: React.FC = ({ group, projectId, categories }) => { - const [edit, setEdit] = useState(false); - const [showMenu, setShowMenu] = useState(false); - const [isEditColProgress, setIsEditColProgress] = useState(false); - const [isGroupByPhases, setIsGroupByPhases] = useState(false); - const [isGroupByStatus, setIsGroupByStatus] = useState(false); - const [isAdmin, setIsAdmin] = useState(false); - - const menu = ( - - - - Rename - - {isGroupByStatus && ( - - - Change category - - } - > - {categories.map(item => ( - - - - - - ))} - - )} - - ); - - const onBlurEditColumn = (group: ITaskListGroup) => { - setEdit(false); - }; - - const onToggleClick = () => { - console.log('onToggleClick'); - }; - - const canDisplayActions = () => { - return true; - }; - - return ( -
-
- - - {canDisplayActions() && ( - setShowMenu(visible)} - > - - - )} -
- - {/* {isGroupByPhases && group.name !== "Unmapped" && ( -
- -
- )} - - {isProgressBarAvailable() && ( - - )} */} -
- ); -}; - -export default TaskListGroupSettings; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-instant-task-input/task-list-instant-task-input.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-instant-task-input/task-list-instant-task-input.tsx deleted file mode 100644 index 2312800a0..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-instant-task-input/task-list-instant-task-input.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { Input, InputRef, theme } from 'antd'; -import React, { useState, useMemo, useRef } from 'react'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { colors } from '@/styles/colors'; -import { useTranslation } from 'react-i18next'; -import { ILocalSession } from '@/types/auth/local-session.types'; -import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types'; -import { - addTask, - getCurrentGroup, - GROUP_BY_PHASE_VALUE, - GROUP_BY_PRIORITY_VALUE, - GROUP_BY_STATUS_VALUE, -} from '@/features/tasks/tasks.slice'; -import { useSocket } from '@/socket/socketContext'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { SocketEvents } from '@/shared/socket-events'; -import { DRAWER_ANIMATION_INTERVAL } from '@/shared/constants'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; - -interface ITaskListInstantTaskInputProps { - session: ILocalSession | null; - groupId?: string | null; - parentTask?: string | null; -} -interface IAddNewTask extends IProjectTask { - groupId: string; -} - -const TaskListInstantTaskInput = ({ - session, - groupId = null, - parentTask = null, -}: ITaskListInstantTaskInputProps) => { - const [isEdit, setIsEdit] = useState(false); - const [taskName, setTaskName] = useState(''); - const [creatingTask, setCreatingTask] = useState(false); - const taskInputRef = useRef(null); - const dispatch = useAppDispatch(); - - const { socket } = useSocket(); - const { token } = theme.useToken(); - - const { t } = useTranslation('task-list-table'); - - const themeMode = useAppSelector(state => state.themeReducer.mode); - const customBorderColor = useMemo(() => themeMode === 'dark' && ' border-[#303030]', [themeMode]); - const projectId = useAppSelector(state => state.projectReducer.projectId); - - const createRequestBody = (): ITaskCreateRequest | null => { - if (!projectId || !session) return null; - const body: ITaskCreateRequest = { - project_id: projectId, - name: taskName, - reporter_id: session.id, - team_id: session.team_id, - }; - - const groupBy = getCurrentGroup(); - if (groupBy.value === GROUP_BY_STATUS_VALUE) { - body.status_id = groupId || undefined; - } else if (groupBy.value === GROUP_BY_PRIORITY_VALUE) { - body.priority_id = groupId || undefined; - } else if (groupBy.value === GROUP_BY_PHASE_VALUE) { - body.phase_id = groupId || undefined; - } - - if (parentTask) { - body.parent_task_id = parentTask; - } - console.log('createRequestBody', body); - - return body; - }; - - const reset = (scroll = true) => { - setIsEdit(false); - - setCreatingTask(false); - - setTaskName(''); - setIsEdit(true); - - setTimeout(() => { - taskInputRef.current?.focus(); - if (scroll) window.scrollTo(0, document.body.scrollHeight); - }, DRAWER_ANIMATION_INTERVAL); // wait for the animation end - }; - - const onNewTaskReceived = (task: IAddNewTask) => { - if (!groupId) return; - console.log('onNewTaskReceived', task); - task.groupId = groupId; - if (groupId && task.id) { - dispatch(addTask(task)); - reset(false); - // if (this.map.has(task.id)) return; - - // this.service.addTask(task, this.groupId); - // this.reset(false); - } - }; - - const addInstantTask = () => { - if (creatingTask) return; - console.log('addInstantTask', projectId, taskName.trim()); - if (!projectId || !session || taskName.trim() === '') return; - - try { - setCreatingTask(true); - const body = createRequestBody(); - if (!body) return; - socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body)); - socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => { - setCreatingTask(false); - if (task.parent_task_id) { - } - onNewTaskReceived(task as IAddNewTask); - }); - } catch (error) { - console.error(error); - } finally { - setCreatingTask(false); - } - }; - - const handleAddTask = () => { - setIsEdit(false); - addInstantTask(); - }; - - return ( -
- {isEdit ? ( - setTaskName(e.target.value)} - onBlur={handleAddTask} - onPressEnter={handleAddTask} - ref={taskInputRef} - /> - ) : ( - setIsEdit(true)} - className="w-[300px] border-none" - style={{ height: '34px' }} - value={t('addTaskText')} - ref={taskInputRef} - /> - )} -
- ); -}; - -export default TaskListInstantTaskInput; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-old/task-list-table-old.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-old/task-list-table-old.tsx deleted file mode 100644 index 23ba76237..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-old/task-list-table-old.tsx +++ /dev/null @@ -1,471 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Avatar, Checkbox, DatePicker, Flex, Tag, Tooltip, Typography } from 'antd'; - -import { useAppSelector } from '@/hooks/useAppSelector'; -import { columnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList'; -import AddTaskListRow from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddTaskListRow'; - -import CustomAvatar from '@/components/CustomAvatar'; -import LabelsSelector from '@components/task-list-common/labelsSelector/labels-selector'; -import { useSelectedProject } from '@/hooks/useSelectedProject'; -import StatusDropdown from '@/components/task-list-common/status-dropdown/status-dropdown'; -import PriorityDropdown from '@/components/task-list-common/priorityDropdown/priority-dropdown'; -import { simpleDateFormat } from '@/utils/simpleDateFormat'; -import { durationDateFormat } from '@/utils/durationDateFormat'; -import CustomColorLabel from '@components/task-list-common/labelsSelector/custom-color-label'; -import CustomNumberLabel from '@components/task-list-common/labelsSelector/custom-number-label'; -import PhaseDropdown from '@components/task-list-common/phaseDropdown/PhaseDropdown'; -import AssigneeSelector from '@components/task-list-common/assigneeSelector/AssigneeSelector'; -import TaskCell from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskCell'; -import AddSubTaskListRow from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddSubTaskListRow'; -import { colors } from '@/styles/colors'; -import TimeTracker from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TimeTracker'; -import TaskContextMenu from '@/pages/projects/project-view-1/taskList/taskListTable/contextMenu/TaskContextMenu'; -import TaskProgress from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; -import { useTranslation } from 'react-i18next'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { ITaskListGroup } from '@/types/tasks/taskList.types'; -import Avatars from '@/components/avatars/avatars'; - -const TaskListTable = ({ - taskList, - tableId, -}: { - taskList: ITaskListGroup; - tableId: string | undefined; -}) => { - // these states manage the necessary states - const [hoverRow, setHoverRow] = useState(null); - const [selectedRows, setSelectedRows] = useState([]); - const [selectedTaskId, setSelectedTaskId] = useState(null); - const [expandedTasks, setExpandedTasks] = useState([]); - const [isSelectAll, setIsSelectAll] = useState(false); - // context menu state - const [contextMenuVisible, setContextMenuVisible] = useState(false); - const [contextMenuPosition, setContextMenuPosition] = useState({ - x: 0, - y: 0, - }); - // state to check scroll - const [scrollingTables, setScrollingTables] = useState<{ - [key: string]: boolean; - }>({}); - - // localization - const { t } = useTranslation('task-list-table'); - - const dispatch = useAppDispatch(); - - // get data theme data from redux - const themeMode = useAppSelector(state => state.themeReducer.mode); - - // get the selected project details - const selectedProject = useSelectedProject(); - - // get columns list details - const columnsVisibility = useAppSelector( - state => state.projectViewTaskListColumnsReducer.columnsVisibility - ); - const visibleColumns = columnList.filter( - column => columnsVisibility[column.key as keyof typeof columnsVisibility] - ); - - // toggle subtasks visibility - const toggleTaskExpansion = (taskId: string) => { - setExpandedTasks(prev => - prev.includes(taskId) ? prev.filter(id => id !== taskId) : [...prev, taskId] - ); - }; - - // toggle all task select when header checkbox click - const toggleSelectAll = () => { - if (isSelectAll) { - setSelectedRows([]); - dispatch(deselectAll()); - } else { - // const allTaskIds = - // task-list?.flatMap((task) => [ - // task.taskId, - // ...(task.subTasks?.map((subtask) => subtask.taskId) || []), - // ]) || []; - // setSelectedRows(allTaskIds); - // dispatch(selectTaskIds(allTaskIds)); - // console.log('selected tasks and subtasks (all):', allTaskIds); - } - setIsSelectAll(!isSelectAll); - }; - - // toggle selected row - const toggleRowSelection = (task: IProjectTask) => { - setSelectedRows(prevSelectedRows => - prevSelectedRows.includes(task.id || '') - ? prevSelectedRows.filter(id => id !== task.id) - : [...prevSelectedRows, task.id || ''] - ); - }; - - // this use effect for realtime update the selected rows - useEffect(() => { - console.log('Selected tasks and subtasks:', selectedRows); - }, [selectedRows]); - - // select one row this triggers only in handle the context menu ==> righ click mouse event - const selectOneRow = (task: IProjectTask) => { - setSelectedRows([task.id || '']); - - // log the task object when selected - if (!selectedRows.includes(task.id || '')) { - console.log('Selected task:', task); - } - }; - - // handle custom task context menu - const handleContextMenu = (e: React.MouseEvent, task: IProjectTask) => { - e.preventDefault(); - setSelectedTaskId(task.id || ''); - selectOneRow(task); - setContextMenuPosition({ x: e.clientX, y: e.clientY }); - setContextMenuVisible(true); - }; - - // trigger the table scrolling - useEffect(() => { - const tableContainer = document.querySelector(`.tasklist-container-${tableId}`); - const handleScroll = () => { - if (tableContainer) { - setScrollingTables(prev => ({ - ...prev, - [tableId]: tableContainer.scrollLeft > 0, - })); - } - }; - tableContainer?.addEventListener('scroll', handleScroll); - return () => tableContainer?.removeEventListener('scroll', handleScroll); - }, [tableId]); - - // layout styles for table and the columns - const customBorderColor = themeMode === 'dark' && ' border-[#303030]'; - - const customHeaderColumnStyles = (key: string) => - `border px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`; - - const customBodyColumnStyles = (key: string) => - `border px-2 ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#141414] border-[#303030]' : 'bg-white'}`; - - // function to render the column content based on column key - const renderColumnContent = ( - columnKey: string, - task: IProjectTask, - isSubtask: boolean = false - ) => { - switch (columnKey) { - // task ID column - case 'taskId': - return ( - - {task.task_key || ''} - - ); - - // task name column - case 'task': - return ( - // custom task cell component - - ); - - // description column - case 'description': - return ; - - // progress column - case 'progress': { - return task?.progress || task?.progress === 0 ? ( - - ) : ( -
- ); - } - - // members column - case 'members': - return ( - - - {/* - {task.assignees?.map(member => ( - - ))} - */} - - - ); - - // labels column - case 'labels': - return ( - - {task?.labels && task?.labels?.length <= 2 ? ( - task?.labels?.map(label => ) - ) : ( - - - - {/* this component show other label names */} - - - )} - - - ); - - // phase column - case 'phases': - return ; - - // status column - case 'status': - return ; - - // priority column - case 'priority': - return ; - - // time tracking column - case 'timeTracking': - return ; - - // estimation column - case 'estimation': - return 0h 0m; - - // start date column - case 'startDate': - return task.start_date ? ( - {simpleDateFormat(task.start_date)} - ) : ( - - ); - - // due date column - case 'dueDate': - return task.end_date ? ( - {simpleDateFormat(task.end_date)} - ) : ( - - ); - - // completed date column - case 'completedDate': - return {durationDateFormat(task.completed_at || null)}; - - // created date column - case 'createdDate': - return {durationDateFormat(task.created_at || null)}; - - // last updated column - case 'lastUpdated': - return {durationDateFormat(task.updated_at || null)}; - - // recorder column - case 'reporter': - return {task.reporter}; - - // default case for unsupported columns - default: - return null; - } - }; - - return ( -
-
- - - - {/* this cell render the select all task checkbox */} - - {/* other header cells */} - {visibleColumns.map(column => ( - - ))} - - - - {taskList?.tasks?.map(task => ( - - handleContextMenu(e, task)} - className={`${taskList.tasks.length === 0 ? 'h-0' : 'h-[42px]'}`} - > - {/* this cell render the select the related task checkbox */} - - {/* other cells */} - {visibleColumns.map(column => ( - - ))} - - - {/* this is for sub tasks */} - {expandedTasks.includes(task.id || '') && - task?.sub_tasks?.map(subtask => ( - handleContextMenu(e, subtask)} - className={`${taskList.tasks.length === 0 ? 'h-0' : 'h-[42px]'}`} - > - {/* this cell render the select the related task checkbox */} - - - {/* other sub tasks cells */} - {visibleColumns.map(column => ( - - ))} - - ))} - - {expandedTasks.includes(task.id || '') && ( - - - - )} - - ))} - -
- - - {column.key === 'phases' - ? column.columnHeader - : t(`${column.columnHeader}Column`)} -
- toggleRowSelection(task)} - /> - - {renderColumnContent(column.key, task)} -
- toggleRowSelection(subtask)} - /> - - {renderColumnContent(column.key, subtask, true)} -
- -
-
- - {/* add a main task to the table */} - - - {/* custom task context menu */} - setContextMenuVisible(false)} - /> -
- ); -}; - -export default TaskListTable; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-old/task-list-table.css b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-old/task-list-table.css deleted file mode 100644 index ce92b84ae..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-old/task-list-table.css +++ /dev/null @@ -1,19 +0,0 @@ -.tasks-table { - width: max-content; - margin-left: 3px; - border-right: 1px solid #f0f0f0; -} - -.flex-table { - display: flex; - width: max-content; -} - -.table-container { - overflow: auto; - display: flex; -} - -.position-relative { - position: relative; -} diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.css b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.css deleted file mode 100644 index 6e5ca7c8e..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.css +++ /dev/null @@ -1,15 +0,0 @@ -/* custom collapse styles for content box and the left border */ -.ant-collapse-header { - margin-bottom: 6px !important; -} - -.custom-collapse-content-box .ant-collapse-content-box { - padding: 0 !important; -} - -:where(.css-dev-only-do-not-override-1w6wsvq).ant-collapse-ghost - > .ant-collapse-item - > .ant-collapse-content - > .ant-collapse-content-box { - padding: 0; -} diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx deleted file mode 100644 index f31497675..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { Badge, Button, Collapse, ConfigProvider, Dropdown, Flex, Input, Typography } from 'antd'; -import { useState } from 'react'; -import { TaskType } from '@/types/task.types'; -import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons'; -import { colors } from '@/styles/colors'; -import './task-list-table-wrapper.css'; -import TaskListTable from '../task-list-table-old/task-list-table-old'; -import { MenuProps } from 'antd/lib'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { useTranslation } from 'react-i18next'; -import { ITaskListGroup } from '@/types/tasks/taskList.types'; -import TaskListCustom from '../task-list-custom'; - -type TaskListTableWrapperProps = { - taskList: ITaskListGroup; - groupId: string | undefined; - name: string | undefined; - color: string | undefined; - onRename?: (name: string) => void; - onStatusCategoryChange?: (category: string) => void; -}; - -const TaskListTableWrapper = ({ - taskList, - groupId, - name, - color, - onRename, - onStatusCategoryChange, -}: TaskListTableWrapperProps) => { - const [tableName, setTableName] = useState(name || ''); - const [isRenaming, setIsRenaming] = useState(false); - const [isExpanded, setIsExpanded] = useState(true); - - const type = 'status'; - - // localization - const { t } = useTranslation('task-list-table'); - - // function to handle toggle expand - const handlToggleExpand = () => { - setIsExpanded(!isExpanded); - }; - - // these codes only for status type tables - // function to handle rename this functionality only available for status type tables - const handleRename = () => { - if (onRename) { - onRename(tableName); - } - setIsRenaming(false); - }; - - // function to handle category change - const handleCategoryChange = (category: string) => { - if (onStatusCategoryChange) { - onStatusCategoryChange(category); - } - }; - - // find the available status for the currently active project - const statusList = useAppSelector(state => state.statusReducer.status); - - const getStatusColor = (status: string) => { - switch (status) { - case 'todo': - return '#d8d7d8'; - case 'doing': - return '#c0d5f6'; - case 'done': - return '#c2e4d0'; - default: - return '#d8d7d8'; - } - }; - - // dropdown options - const items: MenuProps['items'] = [ - { - key: '1', - icon: , - label: 'Rename', - onClick: () => setIsRenaming(true), - }, - { - key: '2', - icon: , - label: 'Change category', - children: statusList?.map(status => ({ - key: status.id, - label: ( - handleCategoryChange(status.category)}> - - {status.name} - - ), - })), - }, - ]; - - return ( - - - - - {type === 'status' && !isRenaming && ( - - - - ); -}; - -export default LabelsFilterDropdown; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/MembersFilterDropdown.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/MembersFilterDropdown.tsx deleted file mode 100644 index 1690b9375..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/MembersFilterDropdown.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -import { CaretDownFilled } from '@ant-design/icons'; -import { - Badge, - Button, - Card, - Checkbox, - Dropdown, - Empty, - Flex, - Input, - InputRef, - List, - Space, - Typography, -} from 'antd'; -import { useMemo, useRef, useState } from 'react'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { colors } from '@/styles/colors'; -import CustomAvatar from '@components/CustomAvatar'; -import { useTranslation } from 'react-i18next'; - -const MembersFilterDropdown = () => { - const [selectedCount, setSelectedCount] = useState(0); - const membersInputRef = useRef(null); - - const members = useAppSelector(state => state.memberReducer.membersList); - - const { t } = useTranslation('task-list-filters'); - - const membersList = [ - ...members, - useAppSelector(state => state.memberReducer.owner), - ]; - - const themeMode = useAppSelector(state => state.themeReducer.mode); - - // this is for get the current string that type on search bar - const [searchQuery, setSearchQuery] = useState(''); - - // used useMemo hook for re render the list when searching - const filteredMembersData = useMemo(() => { - return membersList.filter(member => - member.memberName.toLowerCase().includes(searchQuery.toLowerCase()) - ); - }, [membersList, searchQuery]); - - // handle selected filters count - const handleSelectedFiltersCount = (checked: boolean) => { - setSelectedCount(prev => (checked ? prev + 1 : prev - 1)); - }; - - // custom dropdown content - const membersDropdownContent = ( - - - setSearchQuery(e.currentTarget.value)} - placeholder={t('searchInputPlaceholder')} - /> - - - {filteredMembersData.length ? ( - filteredMembersData.map(member => ( - - handleSelectedFiltersCount(e.target.checked)} - /> -
- -
- - {member.memberName} - - - {member.memberEmail} - - -
- )) - ) : ( - - )} -
-
-
- ); - - // function to focus members input - const handleMembersDropdownOpen = (open: boolean) => { - if (open) { - setTimeout(() => { - membersInputRef.current?.focus(); - }, 0); - } - }; - - return ( - membersDropdownContent} - onOpenChange={handleMembersDropdownOpen} - > - - - ); -}; - -export default MembersFilterDropdown; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/PriorityFilterDropdown.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/PriorityFilterDropdown.tsx deleted file mode 100644 index dafc5af29..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/PriorityFilterDropdown.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { CaretDownFilled } from '@ant-design/icons'; -import { Badge, Button, Card, Checkbox, Dropdown, List, Space } from 'antd'; -import { useState } from 'react'; - -import { colors } from '@/styles/colors'; -import { useTranslation } from 'react-i18next'; -import { ITaskPriority } from '@/types/tasks/taskPriority.types'; -import { useAppSelector } from '@/hooks/useAppSelector'; - -const PriorityFilterDropdown = (props: { priorities: ITaskPriority[] }) => { - const [selectedCount, setSelectedCount] = useState(0); - - // localization - const { t } = useTranslation('task-list-filters'); - const themeMode = useAppSelector(state => state.themeReducer.mode); - - // handle selected filters count - const handleSelectedFiltersCount = (checked: boolean) => { - setSelectedCount(prev => (checked ? prev + 1 : prev - 1)); - }; - - // custom dropdown content - const priorityDropdownContent = ( - - - {props.priorities?.map(item => ( - - - handleSelectedFiltersCount(e.target.checked)} /> - - {item.name} - - - ))} - - - ); - - return ( - priorityDropdownContent} - > - - - ); -}; - -export default PriorityFilterDropdown; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/SearchDropdown.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/SearchDropdown.tsx deleted file mode 100644 index 8eb0d3b0b..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/SearchDropdown.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { SearchOutlined } from '@ant-design/icons'; -import { Button, Card, Dropdown, Flex, Input, InputRef, Space } from 'antd'; -import { useRef } from 'react'; -import { useTranslation } from 'react-i18next'; - -const SearchDropdown = () => { - // localization - const { t } = useTranslation('task-list-filters'); - - const searchInputRef = useRef(null); - - const handleSearchInputChange = (e: React.ChangeEvent) => { - const value = e.target.value; - }; - - // custom dropdown content - const searchDropdownContent = ( - - - - - - - - - - ); - - // function to focus search input - const handleSearchDropdownOpen = (open: boolean) => { - if (open) { - setTimeout(() => { - searchInputRef.current?.focus(); - }, 0); - } - }; - - return ( - searchDropdownContent} - onOpenChange={handleSearchDropdownOpen} - > - - - ); -}; - -export default ShowFieldsFilterDropdown; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/SortFilterDropdown.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/SortFilterDropdown.tsx deleted file mode 100644 index 85c2cfd53..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/SortFilterDropdown.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { CaretDownFilled, SortAscendingOutlined, SortDescendingOutlined } from '@ant-design/icons'; -import { Badge, Button, Card, Checkbox, Dropdown, List, Space } from 'antd'; -import React, { useState } from 'react'; -import { colors } from '../../../../../styles/colors'; -import { useTranslation } from 'react-i18next'; -import { useAppSelector } from '@/hooks/useAppSelector'; - -const SortFilterDropdown = () => { - const [selectedCount, setSelectedCount] = useState(0); - const [sortState, setSortState] = useState>({}); - - const themeMode = useAppSelector(state => state.themeReducer.mode); - - // localization - const { t } = useTranslation('task-list-filters'); - - // handle selected filters count - const handleSelectedFiltersCount = (checked: boolean) => { - setSelectedCount(prev => (checked ? prev + 1 : prev - 1)); - }; - - // fuction for handle sort - const handleSort = (key: string) => { - setSortState(prev => ({ - ...prev, - [key]: prev[key] === 'ascending' ? 'descending' : 'ascending', - })); - }; - - // sort dropdown items - type SortFieldsType = { - key: string; - label: string; - }; - - const sortFieldsList: SortFieldsType[] = [ - { key: 'task', label: t('taskText') }, - { key: 'status', label: t('statusText') }, - { key: 'priority', label: t('priorityText') }, - { key: 'startDate', label: t('startDateText') }, - { key: 'endDate', label: t('endDateText') }, - { key: 'completedDate', label: t('completedDateText') }, - { key: 'createdDate', label: t('createdDateText') }, - { key: 'lastUpdated', label: t('lastUpdatedText') }, - ]; - - // custom dropdown content - const sortDropdownContent = ( - - - {sortFieldsList.map(item => ( - - - handleSelectedFiltersCount(e.target.checked)} - /> - {item.label} - - - - ); -}; - -export default SortFilterDropdown; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/TaskListFilters.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/TaskListFilters.tsx deleted file mode 100644 index d794c4142..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/TaskListFilters.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Checkbox, Flex, Typography } from 'antd'; -import SearchDropdown from './SearchDropdown'; -import SortFilterDropdown from './SortFilterDropdown'; -import LabelsFilterDropdown from './LabelsFilterDropdown'; -import MembersFilterDropdown from './MembersFilterDropdown'; -import GroupByFilterDropdown from './GroupByFilterDropdown'; -import ShowFieldsFilterDropdown from './ShowFieldsFilterDropdown'; -import PriorityFilterDropdown from './PriorityFilterDropdown'; -import { useTranslation } from 'react-i18next'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { useEffect } from 'react'; -import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice'; -import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; - -interface TaskListFiltersProps { - position: 'board' | 'list'; -} - -const TaskListFilters: React.FC = ({ position }) => { - const { t } = useTranslation('task-list-filters'); - const dispatch = useAppDispatch(); - - // Selectors - const priorities = useAppSelector(state => state.priorityReducer.priorities); - const labels = useAppSelector(state => state.taskLabelsReducer.labels); - - // Fetch initial data - useEffect(() => { - const fetchInitialData = async () => { - if (!priorities.length) { - await dispatch(fetchPriorities()); - } - if (!labels.length) { - await dispatch(fetchLabels()); - } - }; - - fetchInitialData(); - }, [dispatch, priorities.length, labels.length]); - - return ( - - - {/* search dropdown */} - - {/* sort dropdown */} - - {/* prioriy dropdown */} - - {/* labels dropdown */} - - {/* members dropdown */} - - {/* group by dropdown */} - {} - - - {position === 'list' && ( - - - - {t('showArchivedText')} - - {/* show fields dropdown */} - - - )} - - ); -}; - -export default TaskListFilters; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/TaskListTable.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/TaskListTable.tsx deleted file mode 100644 index 218ccc2dd..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/TaskListTable.tsx +++ /dev/null @@ -1,425 +0,0 @@ -import { useAppSelector } from '@/hooks/useAppSelector'; -import { columnList } from './columns/columnList'; -import AddTaskListRow from './taskListTableRows/AddTaskListRow'; -import { Checkbox, Flex, Tag, Tooltip } from 'antd'; -import React, { useEffect, useState } from 'react'; -import { useSelectedProject } from '@/hooks/useSelectedProject'; -import TaskCell from './taskListTableCells/TaskCell'; -import AddSubTaskListRow from './taskListTableRows/AddSubTaskListRow'; -import { colors } from '@/styles/colors'; -import TaskContextMenu from './contextMenu/TaskContextMenu'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { deselectAll } from '@features/projects/bulkActions/bulkActionSlice'; -import { useTranslation } from 'react-i18next'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { HolderOutlined } from '@ant-design/icons'; - -const TaskListTable = ({ - taskList, - tableId, -}: { - taskList: IProjectTask[] | null; - tableId: string; -}) => { - // these states manage the necessary states - const [hoverRow, setHoverRow] = useState(null); - const [selectedRows, setSelectedRows] = useState([]); - const [selectedTaskId, setSelectedTaskId] = useState(null); - const [expandedTasks, setExpandedTasks] = useState([]); - const [isSelectAll, setIsSelectAll] = useState(false); - // context menu state - const [contextMenuVisible, setContextMenuVisible] = useState(false); - const [contextMenuPosition, setContextMenuPosition] = useState({ - x: 0, - y: 0, - }); - // state to check scroll - const [scrollingTables, setScrollingTables] = useState<{ - [key: string]: boolean; - }>({}); - - // localization - const { t } = useTranslation('task-list-table'); - - const dispatch = useAppDispatch(); - - // get data theme data from redux - const themeMode = useAppSelector(state => state.themeReducer.mode); - - // get the selected project details - const selectedProject = useSelectedProject(); - - // get columns list details - const columnsVisibility = useAppSelector( state => state.projectViewTaskListColumnsReducer.columnList ); - const visibleColumns = columnList.filter( - column => columnsVisibility[column.key as keyof typeof columnsVisibility] - ); - - // toggle subtasks visibility - const toggleTaskExpansion = (taskId: string) => { - setExpandedTasks(prev => - prev.includes(taskId) ? prev.filter(id => id !== taskId) : [...prev, taskId] - ); - }; - - // toggle all task select when header checkbox click - const toggleSelectAll = () => { - if (isSelectAll) { - setSelectedRows([]); - dispatch(deselectAll()); - } else { - const allTaskIds = - taskList?.flatMap(task => [ - task.id, - ...(task.sub_tasks?.map(subtask => subtask.id) || []), - ]) || []; - - // setSelectedRows(allTaskIds); - // dispatch(selectTaskIds(allTaskIds)); - // console.log('selected tasks and subtasks (all):', allTaskIds); - } - setIsSelectAll(!isSelectAll); - }; - - // toggle selected row - const toggleRowSelection = (task: IProjectTask) => { - setSelectedRows(prevSelectedRows => - prevSelectedRows.includes(task.id || '') - ? prevSelectedRows.filter(id => id !== task.id || '') - : [...prevSelectedRows, task.id || ''] - ); - }; - - // this use effect for realtime update the selected rows - useEffect(() => { - console.log('Selected tasks and subtasks:', selectedRows); - }, [selectedRows]); - - // select one row this triggers only in handle the context menu ==> righ click mouse event - const selectOneRow = (task: IProjectTask) => { - setSelectedRows([task.id || '']); - - // log the task object when selected - if (!selectedRows.includes(task.id || '')) { - console.log('Selected task:', task); - } - }; - - // handle custom task context menu - const handleContextMenu = (e: React.MouseEvent, task: IProjectTask) => { - e.preventDefault(); - setSelectedTaskId(task.id || ''); - selectOneRow(task); - setContextMenuPosition({ x: e.clientX, y: e.clientY }); - setContextMenuVisible(true); - }; - - // trigger the table scrolling - useEffect(() => { - const tableContainer = document.querySelector(`.tasklist-container-${tableId}`); - const handleScroll = () => { - if (tableContainer) { - setScrollingTables(prev => ({ - ...prev, - [tableId]: tableContainer.scrollLeft > 0, - })); - } - }; - tableContainer?.addEventListener('scroll', handleScroll); - return () => tableContainer?.removeEventListener('scroll', handleScroll); - }, [tableId]); - - // layout styles for table and the columns - const customBorderColor = themeMode === 'dark' && ' border-[#303030]'; - - const customHeaderColumnStyles = (key: string) => - `border px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`; - - const customBodyColumnStyles = (key: string) => - `border px-2 ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#141414] border-[#303030]' : 'bg-white'}`; - - // function to render the column content based on column key - const renderColumnContent = ( - columnKey: string, - task: IProjectTask, - isSubtask: boolean = false - ) => { - switch (columnKey) { - // task ID column - case 'taskId': - return ( - - {task.task_key} - - ); - - // task column - case 'task': - return ( - // custom task cell component - - ); - - // description column - case 'description': - return ( -
- {/* - {task.description || ''} - */} -
- ); - - // progress column - case 'progress': { - return
; - } - - // members column - case 'members': - return
; - - // labels column - case 'labels': - return
; - - // phase column - case 'phases': - return
; - - // status column - case 'status': - return
; - - // priority column - case 'priority': - return
; - - // // time tracking column - // case 'timeTracking': - // return ( - // - // ); - - // estimation column - case 'estimation': - return
; - - // start date column - case 'startDate': - return
; - - // due date column - case 'dueDate': - return
; - - // completed date column - case 'completedDate': - return
; - - // created date column - case 'createdDate': - return
; - - // last updated column - case 'lastUpdated': - return
; - - // recorder column - case 'reporter': - return
; - - // default case for unsupported columns - default: - return null; - } - }; - - return ( -
-
- - - - {/* this cell render the select all task checkbox */} - - {/* other header cells */} - {visibleColumns.map(column => ( - - ))} - - - - {taskList?.map(task => ( - - handleContextMenu(e, task)} - className={`${taskList.length === 0 ? 'h-0' : 'h-[42px]'}`} - > - {/* this cell render the select the related task checkbox */} - - {/* other cells */} - {visibleColumns.map(column => ( - - ))} - - - {/* this is for sub tasks */} - {expandedTasks.includes(task.id || '') && - task?.sub_tasks?.map(subtask => ( - handleContextMenu(e, subtask)} - onMouseEnter={() => setHoverRow(subtask.id || '')} - onMouseLeave={() => setHoverRow(null)} - className={`${taskList.length === 0 ? 'h-0' : 'h-[42px]'}`} - > - {/* this cell render the select the related task checkbox */} - - - {/* other sub tasks cells */} - {visibleColumns.map(column => ( - - ))} - - ))} - - {expandedTasks.includes(task.id || '') && ( - - - - )} - - ))} - -
- - - - - {column.key === 'phases' - ? column.columnHeader - : t(`${column.columnHeader}Column`)} -
- - - toggleRowSelection(task)} - /> - - - {renderColumnContent(column.key, task)} -
- toggleRowSelection(subtask)} - /> - - {renderColumnContent(column.key, subtask, true)} -
- -
-
- - {/* add a main task to the table */} - - - {/* custom task context menu */} - setContextMenuVisible(false)} - /> -
- ); -}; - -export default TaskListTable; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/TaskListTableWrapper.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/TaskListTableWrapper.tsx deleted file mode 100644 index d73c2714f..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/TaskListTableWrapper.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { Badge, Button, Collapse, ConfigProvider, Dropdown, Flex, Input, Typography } from 'antd'; -import { useState } from 'react'; -import { TaskType } from '../../../../../types/task.types'; -import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons'; -import { colors } from '../../../../../styles/colors'; -import './taskListTableWrapper.css'; -import TaskListTable from './TaskListTable'; -import { MenuProps } from 'antd/lib'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { useTranslation } from 'react-i18next'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; - -type TaskListTableWrapperProps = { - taskList: IProjectTask[]; - tableId: string; - type: string; - name: string; - color: string; - statusCategory?: string | null; - priorityCategory?: string | null; - onRename?: (name: string) => void; - onStatusCategoryChange?: (category: string) => void; -}; - -const TaskListTableWrapper = ({ - taskList, - tableId, - name, - type, - color, - statusCategory = null, - priorityCategory = null, - onRename, - onStatusCategoryChange, -}: TaskListTableWrapperProps) => { - const [tableName, setTableName] = useState(name); - const [isRenaming, setIsRenaming] = useState(false); - const [isExpanded, setIsExpanded] = useState(true); - const [currentCategory, setCurrentCategory] = useState(statusCategory); - - // localization - const { t } = useTranslation('task-list-table'); - - // function to handle toggle expand - const handlToggleExpand = () => { - setIsExpanded(!isExpanded); - }; - - const themeMode = useAppSelector(state => state.themeReducer.mode); - - // this is for get the color for every typed tables - const getBgColorClassName = (type: string) => { - switch (type) { - case 'status': - if (currentCategory === 'todo') - return themeMode === 'dark' ? 'after:bg-[#3a3a3a]' : 'after:bg-[#d8d7d8]'; - else if (currentCategory === 'doing') - return themeMode === 'dark' ? 'after:bg-[#3d506e]' : 'after:bg-[#c0d5f6]'; - else if (currentCategory === 'done') - return themeMode === 'dark' ? 'after:bg-[#3b6149]' : 'after:bg-[#c2e4d0]'; - else return themeMode === 'dark' ? 'after:bg-[#3a3a3a]' : 'after:bg-[#d8d7d8]'; - - case 'priority': - if (priorityCategory === 'low') - return themeMode === 'dark' ? 'after:bg-[#3b6149]' : 'after:bg-[#c2e4d0]'; - else if (priorityCategory === 'medium') - return themeMode === 'dark' ? 'after:bg-[#916c33]' : 'after:bg-[#f9e3b1]'; - else if (priorityCategory === 'high') - return themeMode === 'dark' ? 'after:bg-[#8b3a3b]' : 'after:bg-[#f6bfc0]'; - else return themeMode === 'dark' ? 'after:bg-[#916c33]' : 'after:bg-[#f9e3b1]'; - default: - return ''; - } - }; - - // these codes only for status type tables - // function to handle rename this functionality only available for status type tables - const handleRename = () => { - if (onRename) { - onRename(tableName); - } - setIsRenaming(false); - }; - - // function to handle category change - const handleCategoryChange = (category: string) => { - setCurrentCategory(category); - if (onStatusCategoryChange) { - onStatusCategoryChange(category); - } - }; - - // find the available status for the currently active project - const statusList = useAppSelector(state => state.statusReducer.status); - - const getStatusColor = (status: string) => { - switch (status) { - case 'todo': - return '#d8d7d8'; - case 'doing': - return '#c0d5f6'; - case 'done': - return '#c2e4d0'; - default: - return '#d8d7d8'; - } - }; - - // dropdown options - const items: MenuProps['items'] = [ - { - key: '1', - icon: , - label: 'Rename', - onClick: () => setIsRenaming(true), - }, - { - key: '2', - icon: , - label: 'Change category', - children: statusList?.map(status => ({ - key: status.id, - label: ( - handleCategoryChange(status.category)}> - - {status.name} - - ), - })), - }, - ]; - - return ( - - - - - {type === 'status' && !isRenaming && ( - - - ); - }; - - // show expand button on hover for tasks without subtasks - const renderToggleButtonForNonSubtasks = (taskId: string, isSubTask: boolean) => { - return !isSubTask ? ( - - ) : ( -
- ); - }; - - // render the double arrow icon and count label for tasks with subtasks - const renderSubtasksCountLabel = (taskId: string, isSubTask: boolean, subTasksCount: number) => { - return ( - !isSubTask && ( - - ) - ); - }; - - return ( - - - {!!task?.sub_tasks?.length && task.id ? ( - renderToggleButtonForHasSubTasks(task.id, !!task?.sub_tasks?.length) - ) : ( -
- )} - - {isSubTask && } - - {task.name} - - {renderSubtasksCountLabel(task.id || '', isSubTask, task?.sub_tasks?.length || 0)} -
- - -
- ); -}; - -export default TaskCell; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.css b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.css deleted file mode 100644 index 64d0697c6..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.css +++ /dev/null @@ -1,22 +0,0 @@ -/* Set the stroke width to 9px for the progress circle */ -.task-progress.ant-progress-circle .ant-progress-circle-path { - stroke-width: 9px !important; /* Adjust the stroke width */ -} - -/* Adjust the inner check mark for better alignment and visibility */ -.task-progress.ant-progress-circle.ant-progress-status-success .ant-progress-inner .anticon-check { - font-size: 8px; /* Adjust font size for the check mark */ - color: green; /* Optional: Set a color */ - transform: translate(-50%, -50%); /* Center align */ - position: absolute; - top: 50%; - left: 50%; - padding: 0; - width: 8px; -} - -/* Adjust the text inside the progress circle */ -.task-progress.ant-progress-circle .ant-progress-text { - font-size: 10px; /* Ensure the text size fits well */ - line-height: 1; -} diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.tsx deleted file mode 100644 index 24df83f92..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Progress, Tooltip } from 'antd'; -import React from 'react'; -import './TaskProgress.css'; - -type TaskProgressProps = { - progress: number; - numberOfSubTasks: number; -}; - -const TaskProgress = ({ progress = 0, numberOfSubTasks = 0 }: TaskProgressProps) => { - const totalTasks = numberOfSubTasks + 1; - const completedTasks = 0; - - const size = progress === 100 ? 21 : 26; - - return ( - - - - ); -}; - -export default TaskProgress; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TimeTracker.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TimeTracker.tsx deleted file mode 100644 index 03496d0a8..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TimeTracker.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; -import { Divider, Empty, Flex, Popover, Typography } from 'antd'; -import { PlayCircleFilled } from '@ant-design/icons'; -import { colors } from '../../../../../../styles/colors'; -import CustomAvatar from '../../../../../../components/CustomAvatar'; -import { mockTimeLogs } from './mockTimeLogs'; - -type TimeTrackerProps = { - taskId: string | null | undefined; - initialTime?: number; -}; - -const TimeTracker = ({ taskId, initialTime = 0 }: TimeTrackerProps) => { - const minutes = Math.floor(initialTime / 60); - const seconds = initialTime % 60; - const formattedTime = `${minutes}m ${seconds}s`; - - const timeTrackingLogCard = - initialTime > 0 ? ( - - {mockTimeLogs.map(log => ( - - - - - - - {log.username} - {` logged ${log.duration} ${ - log.via ? `via ${log.via}` : '' - }`} - - - {log.date} - - - - - - ))} - - ) : ( - - ); - - return ( - - - - Time Tracking Log - - - } - content={timeTrackingLogCard} - trigger="click" - placement="bottomRight" - > - {formattedTime} - - - ); -}; - -export default TimeTracker; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddSubTaskListRow.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddSubTaskListRow.tsx deleted file mode 100644 index 5fc71fdf6..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddSubTaskListRow.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Input } from 'antd'; -import React, { useState } from 'react'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { colors } from '@/styles/colors'; -import { useTranslation } from 'react-i18next'; - -const AddSubTaskListRow = () => { - const [isEdit, setIsEdit] = useState(false); - - // localization - const { t } = useTranslation('task-list-table'); - - // get data theme data from redux - const themeMode = useAppSelector(state => state.themeReducer.mode); - const customBorderColor = themeMode === 'dark' && ' border-[#303030]'; - - return ( -
- {isEdit ? ( - setIsEdit(false)} - /> - ) : ( - setIsEdit(true)} - className="w-[300px] border-none" - value={t('addSubTaskText')} - /> - )} -
- ); -}; - -export default AddSubTaskListRow; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddTaskListRow.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddTaskListRow.tsx deleted file mode 100644 index fa7e9f852..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddTaskListRow.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Input } from 'antd'; -import React, { useState } from 'react'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { colors } from '@/styles/colors'; -import { useTranslation } from 'react-i18next'; - -const AddTaskListRow = () => { - const [isEdit, setIsEdit] = useState(false); - - // localization - const { t } = useTranslation('task-list-table'); - - // get data theme data from redux - const themeMode = useAppSelector(state => state.themeReducer.mode); - const customBorderColor = themeMode === 'dark' && ' border-[#303030]'; - - return ( -
- {isEdit ? ( - setIsEdit(false)} - /> - ) : ( - setIsEdit(true)} - className="w-[300px] border-none" - value={t('addTaskText')} - /> - )} -
- ); -}; - -export default AddTaskListRow; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableWrapper.css b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableWrapper.css deleted file mode 100644 index 6e5ca7c8e..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableWrapper.css +++ /dev/null @@ -1,15 +0,0 @@ -/* custom collapse styles for content box and the left border */ -.ant-collapse-header { - margin-bottom: 6px !important; -} - -.custom-collapse-content-box .ant-collapse-content-box { - padding: 0 !important; -} - -:where(.css-dev-only-do-not-override-1w6wsvq).ant-collapse-ghost - > .ant-collapse-item - > .ant-collapse-content - > .ant-collapse-content-box { - padding: 0; -} diff --git a/worklenz-frontend/src/pages/projects/project-view-1/updates/project-view-updates.css b/worklenz-frontend/src/pages/projects/project-view-1/updates/project-view-updates.css deleted file mode 100644 index 9bf0cab2b..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/updates/project-view-updates.css +++ /dev/null @@ -1,19 +0,0 @@ -.mentions-light .mentions { - background-color: #e9e2e2; - font-weight: 500; - border-radius: 4px; - padding: 2px 4px; -} - -.mentions-dark .mentions { - background-color: #2c2c2c; - font-weight: 500; - border-radius: 4px; - padding: 2px 4px; -} - -.tooltip-comment .mentions { - background-color: transparent; - font-weight: 500; - padding: 0; -} diff --git a/worklenz-frontend/src/pages/projects/project-view-1/updates/project-view-updates.tsx b/worklenz-frontend/src/pages/projects/project-view-1/updates/project-view-updates.tsx deleted file mode 100644 index 684cb5f95..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/updates/project-view-updates.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { Button, ConfigProvider, Flex, Form, Mentions, Skeleton, Space, Tooltip, Typography } from 'antd'; -import { useEffect, useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import DOMPurify from 'dompurify'; -import { useParams } from 'react-router-dom'; - -import CustomAvatar from '@components/CustomAvatar'; -import { colors } from '@/styles/colors'; -import { - IMentionMemberSelectOption, - IMentionMemberViewModel, -} from '@/types/project/projectComments.types'; -import { projectsApiService } from '@/api/projects/projects.api.service'; -import { projectCommentsApiService } from '@/api/projects/comments/project-comments.api.service'; -import { IProjectUpdateCommentViewModel } from '@/types/project/project.types'; -import { calculateTimeDifference } from '@/utils/calculate-time-difference'; -import { getUserSession } from '@/utils/session-helper'; -import './project-view-updates.css'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { DeleteOutlined } from '@ant-design/icons'; - -const MAX_COMMENT_LENGTH = 2000; - -const ProjectViewUpdates = () => { - const { projectId } = useParams(); - const [characterLength, setCharacterLength] = useState(0); - const [isCommentBoxExpand, setIsCommentBoxExpand] = useState(false); - const [members, setMembers] = useState([]); - const [selectedMembers, setSelectedMembers] = useState<{ id: string; name: string }[]>([]); - const [comments, setComments] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isLoadingComments, setIsLoadingComments] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [commentValue, setCommentValue] = useState(''); - const theme = useAppSelector(state => state.themeReducer.mode); - const { refreshTimestamp } = useAppSelector(state => state.projectReducer); - - const { t } = useTranslation('project-view-updates'); - const [form] = Form.useForm(); - - const getMembers = useCallback(async () => { - if (!projectId) return; - try { - setIsLoading(true); - const res = await projectCommentsApiService.getMentionMembers(projectId, 1, 15, null, null, null); - if (res.done) { - setMembers(res.body as IMentionMemberViewModel[]); - } - } catch (error) { - console.error('Failed to fetch members:', error); - } finally { - setIsLoading(false); - } - }, [projectId]); - - const getComments = useCallback(async () => { - if (!projectId) return; - try { - setIsLoadingComments(true); - const res = await projectCommentsApiService.getByProjectId(projectId); - if (res.done) { - setComments(res.body); - } - } catch (error) { - console.error('Failed to fetch comments:', error); - } finally { - setIsLoadingComments(false); - } - }, [projectId]); - - const handleAddComment = async () => { - if (!projectId || characterLength === 0) return; - - try { - setIsSubmitting(true); - - if (!commentValue) { - console.error('Comment content is empty'); - return; - } - - const body = { - project_id: projectId, - team_id: getUserSession()?.team_id, - content: commentValue.trim(), - mentions: selectedMembers - }; - - const res = await projectCommentsApiService.createProjectComment(body); - if (res.done) { - await getComments(); - handleCancel(); - } - } catch (error) { - console.error('Failed to add comment:', error); - } finally { - setIsSubmitting(false); - setCommentValue(''); - - - } - }; - - useEffect(() => { - void getMembers(); - void getComments(); - }, [getMembers, getComments,refreshTimestamp]); - - const handleCancel = useCallback(() => { - form.resetFields(['comment']); - setCharacterLength(0); - setIsCommentBoxExpand(false); - setSelectedMembers([]); - }, [form]); - - const mentionsOptions = - members?.map(member => ({ - value: member.id, - label: member.name, - })) ?? []; - - const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => { - if (!member?.value || !member?.label) return; - setSelectedMembers(prev => - prev.some(mention => mention.id === member.value) - ? prev - : [...prev, { id: member.value, name: member.label }] - ); - - setCommentValue(prev => { - const parts = prev.split('@'); - const lastPart = parts[parts.length - 1]; - const mentionText = member.label; - // Keep only the part before the @ and add the new mention - return prev.slice(0, prev.length - lastPart.length) + mentionText; - }); - }, []); - - const handleCommentChange = useCallback((value: string) => { - // Only update the value without trying to replace mentions - setCommentValue(value); - setCharacterLength(value.trim().length); - }, []); - - const handleDeleteComment = useCallback( - async (commentId: string | undefined) => { - if (!commentId) return; - try { - const res = await projectCommentsApiService.deleteComment(commentId); - if (res.done) { - void getComments(); - } - } catch (error) { - console.error('Failed to delete comment:', error); - } - }, - [getComments] - ); - - return ( - - - { - isLoadingComments ? ( - - ): - comments.map(comment => ( - - - - - - {comment.created_by || ''} - - - - {calculateTimeDifference(comment.created_at || '')} - - - - -
- - - - - - - )} - - - ); -}; - -export default ProjectViewUpdates; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/workload/ProjectViewWorkload.tsx b/worklenz-frontend/src/pages/projects/project-view-1/workload/ProjectViewWorkload.tsx deleted file mode 100644 index c36ea1483..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/workload/ProjectViewWorkload.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -const ProjectViewWorkload = () => { - return
ProjectViewWorkload
; -}; - -export default ProjectViewWorkload; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/workload/projectViewWorkload.css b/worklenz-frontend/src/pages/projects/project-view-1/workload/projectViewWorkload.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx new file mode 100644 index 000000000..10acc136e --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx @@ -0,0 +1,284 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { Flex, Typography, Empty } from 'antd'; +import { themeWiseColor } from '@/utils/themeWiseColor'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useTranslation } from 'react-i18next'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { openFinanceDrawer } from '@/features/finance/finance-slice'; +import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns'; +import FinanceTable from './finance-table'; +import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer'; +import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; +import { createPortal } from 'react-dom'; + +interface FinanceTableWrapperProps { + activeTablesList: IProjectFinanceGroup[]; + loading: boolean; +} + +// Utility function to format seconds to time string +const formatSecondsToTimeString = (totalSeconds: number): string => { + if (!totalSeconds || totalSeconds === 0) return "0s"; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + + return parts.join(' '); +}; + +const FinanceTableWrapper: React.FC = ({ activeTablesList, loading }) => { + const [isScrolling, setIsScrolling] = useState(false); + + const { t } = useTranslation('project-view-finance'); + const dispatch = useAppDispatch(); + + const onTaskClick = (task: any) => { + dispatch(openFinanceDrawer(task)); + }; + + useEffect(() => { + const tableContainer = document.querySelector('.tasklist-container'); + const handleScroll = () => { + if (tableContainer) { + setIsScrolling(tableContainer.scrollLeft > 0); + } + }; + + tableContainer?.addEventListener('scroll', handleScroll); + return () => { + tableContainer?.removeEventListener('scroll', handleScroll); + }; + }, []); + + + + const themeMode = useAppSelector(state => state.themeReducer.mode); + const currency = useAppSelector(state => state.projectFinances.project?.currency || "").toUpperCase(); + const taskGroups = useAppSelector(state => state.projectFinances.taskGroups); + + // Use Redux store data for totals calculation to ensure reactivity + const totals = useMemo(() => { + // Recursive function to calculate totals from task hierarchy without double counting + const calculateTaskTotalsRecursively = (tasks: IProjectFinanceTask[]): any => { + return tasks.reduce((acc, task) => { + // For parent tasks with subtasks, aggregate values from subtasks only + // For leaf tasks, use their individual values + if (task.sub_tasks && task.sub_tasks.length > 0) { + // Parent task - only use aggregated values from subtasks (no parent's own values) + const subtaskTotals = calculateTaskTotalsRecursively(task.sub_tasks); + return { + hours: acc.hours + subtaskTotals.hours, + cost: acc.cost + subtaskTotals.cost, + fixedCost: acc.fixedCost + subtaskTotals.fixedCost, + totalBudget: acc.totalBudget + subtaskTotals.totalBudget, + totalActual: acc.totalActual + subtaskTotals.totalActual, + variance: acc.variance + subtaskTotals.variance, + total_time_logged: acc.total_time_logged + subtaskTotals.total_time_logged, + estimated_cost: acc.estimated_cost + subtaskTotals.estimated_cost + }; + } else { + // Leaf task - calculate values from individual task properties + const leafTotalActual = (task.actual_cost_from_logs || 0) + (task.fixed_cost || 0); + const leafTotalBudget = (task.estimated_cost || 0) + (task.fixed_cost || 0); + return { + hours: acc.hours + (task.estimated_seconds || 0), + cost: acc.cost + (task.actual_cost_from_logs || 0), + fixedCost: acc.fixedCost + (task.fixed_cost || 0), + totalBudget: acc.totalBudget + leafTotalBudget, + totalActual: acc.totalActual + leafTotalActual, + variance: acc.variance + (leafTotalActual - leafTotalBudget), + total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), + estimated_cost: acc.estimated_cost + (task.estimated_cost || 0) + }; + } + }, { + hours: 0, + cost: 0, + fixedCost: 0, + totalBudget: 0, + totalActual: 0, + variance: 0, + total_time_logged: 0, + estimated_cost: 0 + }); + }; + + return taskGroups.reduce((acc, table: IProjectFinanceGroup) => { + const groupTotals = calculateTaskTotalsRecursively(table.tasks); + return { + hours: acc.hours + groupTotals.hours, + cost: acc.cost + groupTotals.cost, + fixedCost: acc.fixedCost + groupTotals.fixedCost, + totalBudget: acc.totalBudget + groupTotals.totalBudget, + totalActual: acc.totalActual + groupTotals.totalActual, + variance: acc.variance + groupTotals.variance, + total_time_logged: acc.total_time_logged + groupTotals.total_time_logged, + estimated_cost: acc.estimated_cost + groupTotals.estimated_cost + }; + }, { + hours: 0, + cost: 0, + fixedCost: 0, + totalBudget: 0, + totalActual: 0, + variance: 0, + total_time_logged: 0, + estimated_cost: 0 + }); + }, [taskGroups]); + + + + const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => { + switch (columnKey) { + case FinanceTableColumnKeys.HOURS: + return ( + + {formatSecondsToTimeString(totals.hours)} + + ); + case FinanceTableColumnKeys.COST: + return {`${totals.cost?.toFixed(2)}`}; + case FinanceTableColumnKeys.FIXED_COST: + return {totals.fixedCost?.toFixed(2)}; + case FinanceTableColumnKeys.TOTAL_BUDGET: + return {totals.totalBudget?.toFixed(2)}; + case FinanceTableColumnKeys.TOTAL_ACTUAL: + return {totals.totalActual?.toFixed(2)}; + case FinanceTableColumnKeys.VARIANCE: + return ( + 0 ? '#FF0000' : '#6DC376', + fontSize: 18, + }} + > + {totals.variance < 0 ? `+${Math.abs(totals.variance).toFixed(2)}` : + totals.variance > 0 ? `-${totals.variance.toFixed(2)}` : + `${totals.variance?.toFixed(2)}`} + + ); + case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: + return ( + + {formatSecondsToTimeString(totals.total_time_logged)} + + ); + case FinanceTableColumnKeys.ESTIMATED_COST: + return ( + + {`${totals.estimated_cost?.toFixed(2)}`} + + ); + default: + return null; + } + }; + + const customColumnHeaderStyles = (key: FinanceTableColumnKeys) => + `px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[68px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`; + + const customColumnStyles = (key: FinanceTableColumnKeys) => + `px-2 text-left ${key === FinanceTableColumnKeys.TASK && `sticky left-0 z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[56px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[56px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#141414]' : 'bg-[#fbfbfb]'}`; + + // Check if there are any tasks across all groups + const hasAnyTasks = activeTablesList.some(table => table.tasks && table.tasks.length > 0); + + return ( + <> + + + + + {financeTableColumns.map(col => ( + + ))} + + + {hasAnyTasks && ( + + {financeTableColumns.map((col, index) => ( + + ))} + + )} + + {hasAnyTasks ? ( + activeTablesList.map((table) => ( + + )) + ) : ( + + + + )} + +
+ + {t(`${col.name}`)} {col.type === 'currency' && `(${currency.toUpperCase()})`} + +
+ {col.key === FinanceTableColumnKeys.TASK ? ( + {t('totalText')} + ) : col.key === FinanceTableColumnKeys.MEMBERS ? null : ( + (col.type === 'hours' || col.type === 'currency') && renderFinancialTableHeaderContent(col.key) + )} +
+ + {t('noTasksFound')} + + } + image={Empty.PRESENTED_IMAGE_SIMPLE} + /> +
+
+ + {createPortal(, document.body)} + + ); +}; + +export default FinanceTableWrapper; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.css b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.css new file mode 100644 index 000000000..e6f6b5442 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.css @@ -0,0 +1,63 @@ +/* Finance Table Styles */ + +/* Enhanced hierarchy visual indicators */ +.finance-table-task-row { + transition: all 0.2s ease-in-out; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); +} + +.dark .finance-table-task-row { + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +/* Hover effect is now handled by inline styles in the component for consistency */ + +/* Nested task styling */ +.finance-table-nested-task { + /* No visual connectors, just clean indentation */ +} + +/* Expand/collapse button styling */ +.finance-table-expand-btn { + transition: all 0.2s ease-in-out; + border-radius: 2px; + padding: 2px; +} + +.finance-table-expand-btn:hover { + background: rgba(0, 0, 0, 0.05); + transform: scale(1.1); +} + +.dark .finance-table-expand-btn:hover { + background: rgba(255, 255, 255, 0.05); +} + +/* Task name styling for different levels */ +.finance-table-task-name { + transition: all 0.2s ease-in-out; +} + +.finance-table-task-name:hover { + color: #40a9ff !important; +} + +/* Fixed cost input styling */ +.fixed-cost-input { + border-radius: 4px; +} + +.fixed-cost-input:focus { + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); +} + +/* Responsive adjustments for nested content */ +@media (max-width: 768px) { + .finance-table-nested-task { + padding-left: 12px; + } + + .finance-table-task-name { + font-size: 12px !important; + } +} \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx new file mode 100644 index 000000000..2423a4c66 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx @@ -0,0 +1,654 @@ +import { Flex, InputNumber, Skeleton, Tooltip, Typography } from 'antd'; +import React, { useEffect, useMemo, useState, useRef } from 'react'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { + DollarCircleOutlined, + DownOutlined, + RightOutlined, +} from '@ant-design/icons'; +import { themeWiseColor } from '@/utils/themeWiseColor'; +import { colors } from '@/styles/colors'; +import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns'; +import Avatars from '@/components/avatars/avatars'; +import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; +import { + updateTaskFixedCostAsync, + toggleTaskExpansion, + fetchSubTasks +} from '@/features/projects/finance/project-finance.slice'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice'; +import { useParams } from 'react-router-dom'; + +import { useAuthService } from '@/hooks/useAuth'; +import { canEditFixedCost } from '@/utils/finance-permissions'; +import './finance-table.css'; +import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice'; +import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice'; + +type FinanceTableProps = { + table: IProjectFinanceGroup; + loading: boolean; + onTaskClick: (task: any) => void; +}; + +const FinanceTable = ({ + table, + loading, + onTaskClick, +}: FinanceTableProps) => { + const [isCollapse, setIsCollapse] = useState(false); + const [isScrolling, setIsScrolling] = useState(false); + const [selectedTask, setSelectedTask] = useState(null); + const [editingFixedCostValue, setEditingFixedCostValue] = useState(null); + const [tasks, setTasks] = useState(table.tasks); + const [hoveredTaskId, setHoveredTaskId] = useState(null); + const saveTimeoutRef = useRef(null); + const dispatch = useAppDispatch(); + + // Get the latest task groups from Redux store + const taskGroups = useAppSelector((state) => state.projectFinances.taskGroups); + + // Auth and permissions + const auth = useAuthService(); + const currentSession = auth.getCurrentSession(); + const { project } = useAppSelector((state) => state.projectReducer); + const hasEditPermission = canEditFixedCost(currentSession, project); + + // Update local state when table.tasks or Redux store changes + useEffect(() => { + const updatedGroup = taskGroups.find(g => g.group_id === table.group_id); + if (updatedGroup) { + setTasks(updatedGroup.tasks); + } else { + setTasks(table.tasks); + } + }, [table.tasks, taskGroups, table.group_id]); + + // Handle click outside to close editing + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (selectedTask && !(event.target as Element)?.closest('.fixed-cost-input')) { + // Save current value before closing if it has changed + if (editingFixedCostValue !== null) { + immediateSaveFixedCost(editingFixedCostValue, selectedTask.id); + } else { + setSelectedTask(null); + setEditingFixedCostValue(null); + } + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [selectedTask, editingFixedCostValue, tasks]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + }; + }, []); + + // get theme data from theme reducer + const themeMode = useAppSelector((state) => state.themeReducer.mode); + + const formatNumber = (value: number | undefined | null) => { + if (value === undefined || value === null) return '0.00'; + return value.toFixed(2); + }; + + // Custom column styles for sticky positioning + const customColumnStyles = (key: FinanceTableColumnKeys) => + `px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-white'}`; + + const customHeaderColumnStyles = (key: FinanceTableColumnKeys) => + `px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`}`; + + const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => { + switch (columnKey) { + case FinanceTableColumnKeys.HOURS: + return {formattedTotals.hours}; + case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: + return {formattedTotals.total_time_logged}; + case FinanceTableColumnKeys.ESTIMATED_COST: + return {formatNumber(formattedTotals.estimated_cost)}; + case FinanceTableColumnKeys.COST: + return {formatNumber(formattedTotals.actual_cost_from_logs)}; + case FinanceTableColumnKeys.FIXED_COST: + return {formatNumber(formattedTotals.fixed_cost)}; + case FinanceTableColumnKeys.TOTAL_BUDGET: + return {formatNumber(formattedTotals.total_budget)}; + case FinanceTableColumnKeys.TOTAL_ACTUAL: + return {formatNumber(formattedTotals.total_actual)}; + case FinanceTableColumnKeys.VARIANCE: + return ( + 0 ? '#FF0000' : '#6DC376' }}> + {formattedTotals.variance < 0 ? '+' + formatNumber(Math.abs(formattedTotals.variance)) : + formattedTotals.variance > 0 ? '-' + formatNumber(formattedTotals.variance) : + formatNumber(formattedTotals.variance)} + + ); + default: + return null; + } + }; + + const handleFixedCostChange = async (value: number | null, taskId: string) => { + const fixedCost = value || 0; + + // Find the task to check if it's a parent task + const findTask = (tasks: IProjectFinanceTask[], id: string): IProjectFinanceTask | null => { + for (const task of tasks) { + if (task.id === id) return task; + if (task.sub_tasks) { + const found = findTask(task.sub_tasks, id); + if (found) return found; + } + } + return null; + }; + + const task = findTask(tasks, taskId); + if (!task) { + console.error('Task not found:', taskId); + return; + } + + // Prevent editing fixed cost for parent tasks + if (task.sub_tasks_count > 0) { + console.warn('Cannot edit fixed cost for parent tasks. Fixed cost is calculated from subtasks.'); + return; + } + + try { + // Update the task fixed cost - this will automatically trigger hierarchical recalculation + // The Redux slice handles parent task updates through recalculateTaskHierarchy + await dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost })).unwrap(); + + setSelectedTask(null); + setEditingFixedCostValue(null); + } catch (error) { + console.error('Failed to update fixed cost:', error); + } + }; + + const { projectId } = useParams<{ projectId: string }>(); + + const handleTaskNameClick = (taskId: string) => { + if (!taskId || !projectId) return; + + dispatch(setSelectedTaskId(taskId)); + dispatch(fetchPhasesByProjectId(projectId)); + dispatch(fetchPriorities()); + dispatch(fetchTask({ taskId, projectId })); + dispatch(setShowTaskDrawer(true)); + }; + + // Handle task expansion/collapse + const handleTaskExpansion = async (task: IProjectFinanceTask) => { + if (!projectId) return; + + // If task has subtasks but they're not loaded yet, load them + if (task.sub_tasks_count > 0 && !task.sub_tasks) { + dispatch(fetchSubTasks({ projectId, parentTaskId: task.id })); + } else { + // Just toggle the expansion state + dispatch(toggleTaskExpansion({ taskId: task.id, groupId: table.group_id })); + } + }; + + // Debounced save function for fixed cost + const debouncedSaveFixedCost = (value: number | null, taskId: string) => { + // Clear existing timeout + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + // Set new timeout + saveTimeoutRef.current = setTimeout(() => { + // Find the current task to check if value actually changed + const findTask = (tasks: IProjectFinanceTask[], id: string): IProjectFinanceTask | null => { + for (const task of tasks) { + if (task.id === id) return task; + if (task.sub_tasks) { + const found = findTask(task.sub_tasks, id); + if (found) return found; + } + } + return null; + }; + + const currentTask = findTask(tasks, taskId); + const currentFixedCost = currentTask?.fixed_cost || 0; + const newFixedCost = value || 0; + + // Only save if the value actually changed + if (newFixedCost !== currentFixedCost && value !== null) { + handleFixedCostChange(value, taskId); + // Don't close the input automatically - let user explicitly close it + } + }, 5000); // Save after 5 seconds of inactivity + }; + + // Immediate save function (for enter/blur) + const immediateSaveFixedCost = (value: number | null, taskId: string) => { + // Clear any pending debounced save + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + + // Find the current task to check if value actually changed + const findTask = (tasks: IProjectFinanceTask[], id: string): IProjectFinanceTask | null => { + for (const task of tasks) { + if (task.id === id) return task; + if (task.sub_tasks) { + const found = findTask(task.sub_tasks, id); + if (found) return found; + } + } + return null; + }; + + const currentTask = findTask(tasks, taskId); + const currentFixedCost = currentTask?.fixed_cost || 0; + const newFixedCost = value || 0; + + // Only save if the value actually changed + if (newFixedCost !== currentFixedCost && value !== null) { + handleFixedCostChange(value, taskId); + } else { + // Just close the editor without saving + setSelectedTask(null); + setEditingFixedCostValue(null); + } + }; + + // Calculate indentation based on nesting level + const getTaskIndentation = (level: number) => level * 32; // 32px per level for better visibility + + // Recursive function to render task hierarchy + const renderTaskHierarchy = (task: IProjectFinanceTask, level: number = 0): React.ReactElement[] => { + const elements: React.ReactElement[] = []; + + // Add the current task + const isHovered = hoveredTaskId === task.id; + const rowIndex = elements.length; + const defaultBg = rowIndex % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode); + const hoverBg = themeMode === 'dark' ? 'rgba(64, 169, 255, 0.08)' : 'rgba(24, 144, 255, 0.04)'; + + elements.push( + 0 ? 'finance-table-nested-task' : ''} ${themeMode === 'dark' ? 'dark' : ''}`} + onMouseEnter={() => setHoveredTaskId(task.id)} + onMouseLeave={() => setHoveredTaskId(null)} + > + {financeTableColumns.map((col) => ( + e.stopPropagation() + : undefined + } + > + {renderFinancialTableColumnContent(col.key, task, level)} + + ))} + + ); + + // Add subtasks recursively if they are expanded and loaded + if (task.show_sub_tasks && task.sub_tasks) { + task.sub_tasks.forEach(subTask => { + elements.push(...renderTaskHierarchy(subTask, level + 1)); + }); + } + + return elements; + }; + + const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask, level: number = 0) => { + switch (columnKey) { + case FinanceTableColumnKeys.TASK: + return ( + + + + {/* Expand/collapse icon for parent tasks */} + {task.sub_tasks_count > 0 && ( +
{ + e.stopPropagation(); + handleTaskExpansion(task); + }} + > + {task.show_sub_tasks ? : } +
+ )} + + {/* Spacer for tasks without subtasks to align with those that have expand icons */} + {task.sub_tasks_count === 0 && level > 0 && ( +
+ )} + + {/* Task name */} + 0 ? 26 : 18)), + cursor: 'pointer', + color: '#1890ff', + fontSize: Math.max(12, 14 - level * 0.3), // Slightly smaller font for deeper levels + opacity: Math.max(0.85, 1 - level * 0.03), // Slightly faded for deeper levels + fontWeight: level > 0 ? 400 : 500 // Slightly lighter weight for nested tasks + }} + onClick={(e) => { + e.stopPropagation(); + handleTaskNameClick(task.id); + }} + onMouseEnter={(e) => { + e.currentTarget.style.textDecoration = 'underline'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.textDecoration = 'none'; + }} + > + {task.name} + + {task.billable && } + + + ); + case FinanceTableColumnKeys.MEMBERS: + return task.members && ( +
{ + e.stopPropagation(); + onTaskClick(task); + }} + style={{ + cursor: 'pointer', + width: '100%' + }} + > + ({ + ...member, + avatar_url: member.avatar_url || undefined + }))} + allowClickThrough={true} + /> +
+ ); + case FinanceTableColumnKeys.HOURS: + return {task.estimated_hours}; + case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: + return {task.total_time_logged}; + case FinanceTableColumnKeys.ESTIMATED_COST: + return {formatNumber(task.estimated_cost)}; + case FinanceTableColumnKeys.FIXED_COST: + // Parent tasks with subtasks should not be editable - they aggregate from subtasks + const isParentTask = task.sub_tasks_count > 0; + const canEditThisTask = hasEditPermission && !isParentTask; + + return selectedTask?.id === task.id && canEditThisTask ? ( + { + setEditingFixedCostValue(value); + // Trigger debounced save for up/down arrow clicks + debouncedSaveFixedCost(value, task.id); + }} + onBlur={() => { + // Immediate save on blur + immediateSaveFixedCost(editingFixedCostValue, task.id); + }} + onPressEnter={() => { + // Immediate save on enter + immediateSaveFixedCost(editingFixedCostValue, task.id); + }} + onFocus={(e) => { + // Select all text when input is focused + e.target.select(); + }} + autoFocus + style={{ width: '100%', textAlign: 'right', fontSize: Math.max(12, 14 - level * 0.5) }} + formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} + parser={(value) => Number(value!.replace(/\$\s?|(,*)/g, ''))} + min={0} + precision={2} + className="fixed-cost-input" + /> + ) : ( + { + e.stopPropagation(); + setSelectedTask(task); + setEditingFixedCostValue(task.fixed_cost); + } : undefined} + title={isParentTask ? 'Fixed cost is calculated from subtasks' : undefined} + > + {formatNumber(task.fixed_cost)} + + ); + case FinanceTableColumnKeys.VARIANCE: + const taskTotalBudgetForVariance = (task.estimated_cost || 0) + (task.fixed_cost || 0); + const taskTotalActualForVariance = (task.actual_cost_from_logs || 0) + (task.fixed_cost || 0); + const taskVariance = taskTotalActualForVariance - taskTotalBudgetForVariance; + return ( + 0 ? '#FF0000' : '#6DC376', + fontSize: Math.max(12, 14 - level * 0.5) + }} + > + {taskVariance < 0 ? '+' + formatNumber(Math.abs(taskVariance)) : + taskVariance > 0 ? '-' + formatNumber(taskVariance) : + formatNumber(taskVariance)} + + ); + case FinanceTableColumnKeys.TOTAL_BUDGET: + const taskTotalBudget = (task.estimated_cost || 0) + (task.fixed_cost || 0); + return {formatNumber(taskTotalBudget)}; + case FinanceTableColumnKeys.TOTAL_ACTUAL: + const taskTotalActual = (task.actual_cost_from_logs || 0) + (task.fixed_cost || 0); + return {formatNumber(taskTotalActual)}; + case FinanceTableColumnKeys.COST: + return {formatNumber(task.actual_cost_from_logs || 0)}; + default: + return null; + } + }; + + // Utility function to format seconds to time string + const formatSecondsToTimeString = (totalSeconds: number): string => { + if (!totalSeconds || totalSeconds === 0) return "0s"; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + + return parts.join(' '); + }; + + // Generate flattened task list with all nested levels + const flattenedTasks = useMemo(() => { + const flattened: React.ReactElement[] = []; + + tasks.forEach(task => { + flattened.push(...renderTaskHierarchy(task, 0)); + }); + + return flattened; + }, [tasks, selectedTask, editingFixedCostValue, hasEditPermission, themeMode, hoveredTaskId]); + + // Calculate totals for the current table + // Optimized calculation that avoids double counting in nested hierarchies + const totals = useMemo(() => { + const calculateTaskTotalsRecursive = (taskList: IProjectFinanceTask[]): any => { + let totals = { + hours: 0, + total_time_logged: 0, + estimated_cost: 0, + actual_cost_from_logs: 0, + fixed_cost: 0, + total_budget: 0, + total_actual: 0, + variance: 0 + }; + + for (const task of taskList) { + if (task.sub_tasks && task.sub_tasks.length > 0) { + // Parent task with loaded subtasks - only use subtasks values (no parent's own values) + const subtaskTotals = calculateTaskTotalsRecursive(task.sub_tasks); + totals.hours += subtaskTotals.hours; + totals.total_time_logged += subtaskTotals.total_time_logged; + totals.estimated_cost += subtaskTotals.estimated_cost; + totals.actual_cost_from_logs += subtaskTotals.actual_cost_from_logs; + totals.fixed_cost += subtaskTotals.fixed_cost; + totals.total_budget += subtaskTotals.total_budget; + totals.total_actual += subtaskTotals.total_actual; + totals.variance += subtaskTotals.variance; + } else { + // Leaf task or parent task without loaded subtasks - use its values directly + const leafTotalActual = (task.actual_cost_from_logs || 0) + (task.fixed_cost || 0); + const leafTotalBudget = (task.estimated_cost || 0) + (task.fixed_cost || 0); + totals.hours += task.estimated_seconds || 0; + totals.total_time_logged += task.total_time_logged_seconds || 0; + totals.estimated_cost += task.estimated_cost || 0; + totals.actual_cost_from_logs += task.actual_cost_from_logs || 0; + totals.fixed_cost += task.fixed_cost || 0; + totals.total_budget += leafTotalBudget; + totals.total_actual += leafTotalActual; + totals.variance += leafTotalActual - leafTotalBudget; + } + } + + return totals; + }; + + return calculateTaskTotalsRecursive(tasks); + }, [tasks]); + + // Format the totals for display + const formattedTotals = useMemo(() => ({ + hours: formatSecondsToTimeString(totals.hours), + total_time_logged: formatSecondsToTimeString(totals.total_time_logged), + estimated_cost: totals.estimated_cost, + actual_cost_from_logs: totals.actual_cost_from_logs, + fixed_cost: totals.fixed_cost, + total_budget: totals.total_budget, + total_actual: totals.total_actual, + variance: totals.variance + }), [totals]); + + if (loading) { + return ( + + + + + + ); + } + + return ( + <> + {/* header row */} + + {financeTableColumns.map( + (col, index) => ( + setIsCollapse((prev) => !prev) : undefined} + > + {col.key === FinanceTableColumnKeys.TASK ? ( + + {isCollapse ? : } + {table.group_name} ({tasks.length}) + + ) : col.key === FinanceTableColumnKeys.MEMBERS ? null : renderFinancialTableHeaderContent(col.key)} + + ) + )} + + + {/* task rows with recursive hierarchy */} + {!isCollapse && flattenedTasks} + + ); +}; + +export default FinanceTable; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx new file mode 100644 index 000000000..459cad88d --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx @@ -0,0 +1,475 @@ +import { Button, ConfigProvider, Flex, Select, Typography, message, Alert, Card, Row, Col, Statistic } from 'antd'; +import { useEffect, useState, useMemo, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { CaretDownFilled, DownOutlined, CalculatorOutlined } from '@ant-design/icons'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { fetchProjectFinances, setActiveTab, setActiveGroup, updateProjectFinanceCurrency, fetchProjectFinancesSilent, setBillableFilter } from '@/features/projects/finance/project-finance.slice'; +import { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice'; +import { updateProjectCurrency } from '@/features/project/project.slice'; +import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; +import { RootState } from '@/app/store'; +import FinanceTableWrapper from './finance-tab/finance-table/finance-table-wrapper'; +import RatecardTable from './ratecard-tab/reatecard-table/ratecard-table'; +import ImportRatecardsDrawer from '@/features/finance/ratecard-drawer/import-ratecards-drawer'; +import { useAuthService } from '@/hooks/useAuth'; +import { hasFinanceEditPermission } from '@/utils/finance-permissions'; +import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from '@/shared/constants/currencies'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; + +const ProjectViewFinance = () => { + const { projectId } = useParams<{ projectId: string }>(); + const dispatch = useAppDispatch(); + const { t } = useTranslation('project-view-finance'); + const [exporting, setExporting] = useState(false); + const [updatingCurrency, setUpdatingCurrency] = useState(false); + const { socket } = useSocket(); + + const { activeTab, activeGroup, billableFilter, loading, taskGroups, project: financeProject } = useAppSelector((state: RootState) => state.projectFinances); + const { refreshTimestamp, project } = useAppSelector((state: RootState) => state.projectReducer); + const phaseList = useAppSelector((state) => state.phaseReducer.phaseList); + + // Auth and permissions + const auth = useAuthService(); + const currentSession = auth.getCurrentSession(); + const hasEditPermission = hasFinanceEditPermission(currentSession, project); + + // Get project-specific currency from finance API response, fallback to project reducer, then default + const projectCurrency = (financeProject?.currency || project?.currency || DEFAULT_CURRENCY).toLowerCase(); + + // Show loading state for currency selector until finance data is loaded + const currencyLoading = loading || updatingCurrency || !financeProject; + + // Calculate project budget statistics + const budgetStatistics = useMemo(() => { + if (!taskGroups || taskGroups.length === 0) { + return { + totalEstimatedCost: 0, + totalFixedCost: 0, + totalBudget: 0, + totalActualCost: 0, + totalVariance: 0, + budgetUtilization: 0 + }; + } + + // Optimized calculation that avoids double counting in nested hierarchies + const calculateTaskTotalsFlat = (tasks: any[]): any => { + let totals = { + totalEstimatedCost: 0, + totalFixedCost: 0, + totalBudget: 0, + totalActualCost: 0, + totalVariance: 0 + }; + + for (const task of tasks) { + // For parent tasks with subtasks, only count the aggregated values (no double counting) + // For leaf tasks, count their individual values + if (task.sub_tasks && task.sub_tasks.length > 0) { + // Parent task - use its aggregated values which already include subtask totals + totals.totalEstimatedCost += task.estimated_cost || 0; + totals.totalFixedCost += task.fixed_cost || 0; + totals.totalBudget += task.total_budget || 0; + totals.totalActualCost += task.total_actual || 0; + totals.totalVariance += task.variance || 0; + } else { + // Leaf task - use its individual values + totals.totalEstimatedCost += task.estimated_cost || 0; + totals.totalFixedCost += task.fixed_cost || 0; + totals.totalBudget += task.total_budget || 0; + totals.totalActualCost += task.total_actual || 0; + totals.totalVariance += task.variance || 0; + } + } + + return totals; + }; + + const totals = taskGroups.reduce((acc, group) => { + const groupTotals = calculateTaskTotalsFlat(group.tasks); + return { + totalEstimatedCost: acc.totalEstimatedCost + groupTotals.totalEstimatedCost, + totalFixedCost: acc.totalFixedCost + groupTotals.totalFixedCost, + totalBudget: acc.totalBudget + groupTotals.totalBudget, + totalActualCost: acc.totalActualCost + groupTotals.totalActualCost, + totalVariance: acc.totalVariance + groupTotals.totalVariance + }; + }, { + totalEstimatedCost: 0, + totalFixedCost: 0, + totalBudget: 0, + totalActualCost: 0, + totalVariance: 0 + }); + + const budgetUtilization = totals.totalBudget > 0 + ? (totals.totalActualCost / totals.totalBudget) * 100 + : 0; + + return { + ...totals, + budgetUtilization + }; + }, [taskGroups]); + + // Silent refresh function for socket events + const refreshFinanceData = useCallback(() => { + if (projectId) { + dispatch(fetchProjectFinancesSilent({ projectId, groupBy: activeGroup, billableFilter })); + } + }, [projectId, activeGroup, billableFilter, dispatch]); + + // Socket event handlers + const handleTaskEstimationChange = useCallback(() => { + refreshFinanceData(); + }, [refreshFinanceData]); + + const handleTaskTimerStop = useCallback(() => { + refreshFinanceData(); + }, [refreshFinanceData]); + + const handleTaskProgressUpdate = useCallback(() => { + refreshFinanceData(); + }, [refreshFinanceData]); + + const handleTaskBillableChange = useCallback(() => { + refreshFinanceData(); + }, [refreshFinanceData]); + + useEffect(() => { + if (projectId) { + dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup, billableFilter })); + } + }, [projectId, activeGroup, billableFilter, dispatch, refreshTimestamp]); + + // Socket event listeners for finance data refresh + useEffect(() => { + if (!socket) return; + + const eventHandlers = [ + { event: SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handler: handleTaskEstimationChange }, + { event: SocketEvents.TASK_TIMER_STOP.toString(), handler: handleTaskTimerStop }, + { event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdate }, + { event: SocketEvents.TASK_BILLABLE_CHANGE.toString(), handler: handleTaskBillableChange }, + ]; + + // Register all event listeners + eventHandlers.forEach(({ event, handler }) => { + socket.on(event, handler); + }); + + // Cleanup function + return () => { + eventHandlers.forEach(({ event, handler }) => { + socket.off(event, handler); + }); + }; + }, [socket, handleTaskEstimationChange, handleTaskTimerStop, handleTaskProgressUpdate, handleTaskBillableChange]); + + const handleExport = async () => { + if (!projectId) { + message.error('Project ID not found'); + return; + } + + try { + setExporting(true); + const blob = await projectFinanceApiService.exportFinanceData(projectId, activeGroup, billableFilter); + + const projectName = project?.name || 'Unknown_Project'; + const sanitizedProjectName = projectName.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '_'); + const dateTime = new Date().toISOString().replace(/[:.]/g, '-').split('T'); + const date = dateTime[0]; + const time = dateTime[1].split('.')[0]; + const filename = `${sanitizedProjectName}_Finance_Data_${date}_${time}.xlsx`; + + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + message.success('Finance data exported successfully'); + } catch (error) { + console.error('Export failed:', error); + message.error('Failed to export finance data'); + } finally { + setExporting(false); + } + }; + + const handleCurrencyChange = async (currency: string) => { + if (!projectId || !hasEditPermission) { + message.error('You do not have permission to change the project currency'); + return; + } + + try { + setUpdatingCurrency(true); + const upperCaseCurrency = currency.toUpperCase(); + await projectFinanceApiService.updateProjectCurrency(projectId, upperCaseCurrency); + + // Update both global currency state and project-specific currency + dispatch(changeCurrency(currency)); + dispatch(updateProjectCurrency(upperCaseCurrency)); + dispatch(updateProjectFinanceCurrency(upperCaseCurrency)); + + message.success('Project currency updated successfully'); + } catch (error) { + console.error('Currency update failed:', error); + message.error('Failed to update project currency'); + } finally { + setUpdatingCurrency(false); + } + }; + + const groupDropdownMenuItems = [ + { key: 'status', value: 'status', label: t('statusText') }, + { key: 'priority', value: 'priority', label: t('priorityText') }, + { + key: 'phases', + value: 'phases', + label: phaseList.length > 0 ? project?.phase_label || t('phaseText') : t('phaseText'), + }, + ]; + + const billableFilterOptions = [ + { key: 'billable', value: 'billable', label: t('billableOnlyText') }, + { key: 'non-billable', value: 'non-billable', label: t('nonBillableOnlyText') }, + { key: 'all', value: 'all', label: t('allTasksText') }, + ]; + + return ( + + {/* Finance Header */} + + + + + + + + + {activeTab === 'finance' && ( + + + {t('groupByText')}: + dispatch(setBillableFilter(value as 'all' | 'billable' | 'non-billable'))} + suffixIcon={} + style={{ minWidth: 140 }} + /> + + + )} + + + {activeTab === 'finance' ? ( + + ) : ( + + + {t('currencyText')} + setAddingRow(false)} + filterOption={(input, option) => + (option?.children as string)?.toLowerCase().includes(input.toLowerCase()) + } + > + {jobTitles + .filter((jt) => !roles.some((role) => role.job_title_id === jt.id)) + .map((jt) => ( + + {jt.name} + + ))} + + ); + } + return {text || record.name}; + }, + }, + { + title: `${t('ratePerHourColumn')} (${currency})`, + dataIndex: 'rate', + align: 'right', + render: (value: number, record: JobRoleType, index: number) => ( + { + if (el) rateInputRefs.current[index] = el as unknown as HTMLInputElement; + }} + type="number" + value={roles[index]?.rate ?? 0} + min={0} + disabled={!hasEditPermission} + style={{ + background: 'transparent', + border: 'none', + boxShadow: 'none', + padding: 0, + width: 80, + textAlign: 'right', + opacity: hasEditPermission ? 1 : 0.7, + cursor: hasEditPermission ? 'text' : 'not-allowed' + }} + onChange={hasEditPermission ? (e) => handleRateChange(e.target.value, index) : undefined} + onBlur={hasEditPermission ? (e) => handleRateBlur(e.target.value, index) : undefined} + onPressEnter={hasEditPermission ? (e) => handleRateBlur(e.target.value, index) : undefined} + /> + ), + }, + { + title: t('membersColumn'), + dataIndex: 'members', + render: (memberscol: string[] | null | undefined, record: JobRoleType, index: number) => ( +
+ + {memberscol?.map((memberId, i) => { + const member = members.find((m) => m.id === memberId); + return member ? ( + + ) : null; + })} + + {canAddMembers && ( +
+ handleMemberChange(memberId, index, record)} + memberlist={members} + assignedMembers={assignedMembers} // Pass assigned members here + /> +
+ )} +
+ ), + }, + { + title: t('actions'), + key: 'actions', + align: 'center', + render: (_: any, record: JobRoleType, index: number) => ( + hasEditPermission ? ( + handleDelete(record, index)} + okText={t('yes')} + cancelText={t('no')} + > +