diff --git a/improved_deserialize_user.sql b/improved_deserialize_user.sql new file mode 100644 index 000000000..d46951f2e --- /dev/null +++ b/improved_deserialize_user.sql @@ -0,0 +1,92 @@ +CREATE OR REPLACE FUNCTION deserialize_user(_id uuid) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _result JSON; +BEGIN + -- Optimized version using CTEs for better performance and maintainability + WITH user_team_data AS ( + SELECT + u.id, + u.name, + u.email, + u.timezone_id AS timezone, + u.avatar_url, + u.user_no, + u.socket_id, + u.created_at AS joined_date, + u.updated_at AS last_updated, + u.setup_completed AS my_setup_completed, + (is_null_or_empty(u.google_id) IS FALSE) AS is_google, + COALESCE(u.active_team, (SELECT id FROM teams WHERE user_id = u.id LIMIT 1)) AS team_id, + u.active_team + FROM users u + WHERE u.id = _id + ), + team_org_data AS ( + SELECT + utd.*, + t.name AS team_name, + t.user_id AS owner_id, + o.subscription_status, + o.license_type_id, + o.trial_expire_date + FROM user_team_data utd + INNER JOIN teams t ON t.id = utd.team_id + LEFT JOIN organizations o ON o.user_id = t.user_id + ), + notification_data AS ( + SELECT + tod.*, + COALESCE(ns.email_notifications_enabled, TRUE) AS email_notifications_enabled + FROM team_org_data tod + LEFT JOIN notification_settings ns ON (ns.user_id = tod.id AND ns.team_id = tod.team_id) + ), + alerts_data AS ( + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(alert_rec))), '[]'::JSON) AS alerts + FROM (SELECT description, type FROM worklenz_alerts WHERE active IS TRUE) alert_rec + ), + complete_user_data AS ( + SELECT + nd.*, + tz.name AS timezone_name, + slt.key AS subscription_type, + tm.id AS team_member_id, + ad.alerts, + CASE + WHEN nd.subscription_status = 'trialing' THEN nd.trial_expire_date::DATE + WHEN EXISTS(SELECT 1 FROM licensing_custom_subs WHERE user_id = nd.owner_id) + THEN (SELECT end_date FROM licensing_custom_subs WHERE user_id = nd.owner_id LIMIT 1)::DATE + WHEN EXISTS(SELECT 1 FROM licensing_user_subscriptions WHERE user_id = nd.owner_id AND active IS TRUE) + THEN (SELECT (next_bill_date)::DATE - INTERVAL '1 day' + FROM licensing_user_subscriptions + WHERE user_id = nd.owner_id AND active IS TRUE + LIMIT 1)::DATE + ELSE NULL + END AS valid_till_date, + CASE + WHEN is_owner(nd.id, nd.active_team) THEN nd.my_setup_completed + ELSE TRUE + END AS setup_completed, + is_owner(nd.id, nd.active_team) AS owner, + is_admin(nd.id, nd.active_team) AS is_admin + FROM notification_data nd + CROSS JOIN alerts_data ad + LEFT JOIN timezones tz ON tz.id = nd.timezone + LEFT JOIN sys_license_types slt ON slt.id = nd.license_type_id + LEFT JOIN team_members tm ON (tm.user_id = nd.id AND tm.team_id = nd.team_id AND tm.active IS TRUE) + ) + SELECT ROW_TO_JSON(complete_user_data.*) INTO _result FROM complete_user_data; + + -- Ensure notification settings exist using INSERT...ON CONFLICT for better concurrency + INSERT INTO notification_settings (user_id, team_id, email_notifications_enabled, popup_notifications_enabled, show_unread_items_count) + SELECT _id, + COALESCE((SELECT active_team FROM users WHERE id = _id), + (SELECT id FROM teams WHERE user_id = _id LIMIT 1)), + TRUE, TRUE, TRUE + ON CONFLICT (user_id, team_id) DO NOTHING; + + RETURN _result; +END +$$; \ No newline at end of file diff --git a/test_sort_fix.sql b/test_sort_fix.sql deleted file mode 100644 index ceb0b0a09..000000000 --- a/test_sort_fix.sql +++ /dev/null @@ -1,41 +0,0 @@ --- Test script to verify the sort order constraint fix - --- Test the helper function -SELECT get_sort_column_name('status'); -- Should return 'status_sort_order' -SELECT get_sort_column_name('priority'); -- Should return 'priority_sort_order' -SELECT get_sort_column_name('phase'); -- Should return 'phase_sort_order' -SELECT get_sort_column_name('members'); -- Should return 'member_sort_order' -SELECT get_sort_column_name('unknown'); -- Should return 'status_sort_order' (default) - --- Test bulk update function (example - would need real project_id and task_ids) -/* -SELECT update_task_sort_orders_bulk( - '[ - {"task_id": "example-uuid", "sort_order": 1, "status_id": "status-uuid"}, - {"task_id": "example-uuid-2", "sort_order": 2, "status_id": "status-uuid"} - ]'::json, - 'status' -); -*/ - --- Verify that sort_order constraint still exists and works -SELECT - tc.constraint_name, - tc.table_name, - kcu.column_name -FROM information_schema.table_constraints tc -JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name -WHERE tc.constraint_name = 'tasks_sort_order_unique'; - --- Check that new sort order columns don't have unique constraints (which is correct) -SELECT - tc.constraint_name, - tc.table_name, - kcu.column_name -FROM information_schema.table_constraints tc -JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name -WHERE kcu.table_name = 'tasks' - AND kcu.column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order') - AND tc.constraint_type = 'UNIQUE'; \ No newline at end of file diff --git a/test_sort_orders.sql b/test_sort_orders.sql deleted file mode 100644 index 6a45de846..000000000 --- a/test_sort_orders.sql +++ /dev/null @@ -1,30 +0,0 @@ --- Test script to validate the separate sort order implementation - --- Check if new columns exist -SELECT column_name, data_type, is_nullable, column_default -FROM information_schema.columns -WHERE table_name = 'tasks' - AND column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order') -ORDER BY column_name; - --- Check if helper function exists -SELECT routine_name, routine_type -FROM information_schema.routines -WHERE routine_name IN ('get_sort_column_name', 'update_task_sort_orders_bulk', 'handle_task_list_sort_order_change'); - --- Sample test data to verify different sort orders work --- (This would be run after the migrations) -/* --- Test: Tasks should have different orders for different groupings -SELECT - id, - name, - sort_order, - status_sort_order, - priority_sort_order, - phase_sort_order, - member_sort_order -FROM tasks -WHERE project_id = '' -ORDER BY status_sort_order; -*/ \ No newline at end of file diff --git a/worklenz-backend/.env.template b/worklenz-backend/.env.template index fdd8fe447..c8fca30cf 100644 --- a/worklenz-backend/.env.template +++ b/worklenz-backend/.env.template @@ -38,6 +38,9 @@ HOSTNAME=localhost:5000 SLACK_WEBHOOK=your_slack_webhook_url USE_PG_NATIVE=false +# Teams Support Webhook +TEAMS_SUPPORT_WEBHOOK=your_teams_webhook_url + # JWT SECRET JWT_SECRET=your_jwt_secret diff --git a/worklenz-backend/database/migrations/release-v2.1.4/20250724000000-add-survey-tables.sql b/worklenz-backend/database/migrations/release-v2.1.4/20250724000000-add-survey-tables.sql new file mode 100644 index 000000000..ad779ae34 --- /dev/null +++ b/worklenz-backend/database/migrations/release-v2.1.4/20250724000000-add-survey-tables.sql @@ -0,0 +1,93 @@ +-- Migration: Add survey tables for account setup questionnaire +-- Date: 2025-07-24 +-- Description: Creates tables to store survey questions and user responses for account setup flow + +BEGIN; + +-- Create surveys table to define different types of surveys +CREATE TABLE IF NOT EXISTS surveys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + survey_type VARCHAR(50) DEFAULT 'account_setup' NOT NULL, -- 'account_setup', 'onboarding', 'feedback' + is_active BOOLEAN DEFAULT TRUE NOT NULL, + created_at TIMESTAMP DEFAULT now() NOT NULL, + updated_at TIMESTAMP DEFAULT now() NOT NULL +); + +-- Create survey_questions table to store individual questions +CREATE TABLE IF NOT EXISTS survey_questions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL, + question_key VARCHAR(100) NOT NULL, -- Used for localization keys + question_type VARCHAR(50) NOT NULL, -- 'single_choice', 'multiple_choice', 'text' + is_required BOOLEAN DEFAULT FALSE NOT NULL, + sort_order INTEGER DEFAULT 0 NOT NULL, + options JSONB, -- For choice questions, store options as JSON array + created_at TIMESTAMP DEFAULT now() NOT NULL, + updated_at TIMESTAMP DEFAULT now() NOT NULL +); + +-- Create survey_responses table to track user responses to surveys +CREATE TABLE IF NOT EXISTS survey_responses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL, + user_id UUID REFERENCES users(id) ON DELETE CASCADE NOT NULL, + is_completed BOOLEAN DEFAULT FALSE NOT NULL, + started_at TIMESTAMP DEFAULT now() NOT NULL, + completed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT now() NOT NULL, + updated_at TIMESTAMP DEFAULT now() NOT NULL +); + +-- Create survey_answers table to store individual question answers +CREATE TABLE IF NOT EXISTS survey_answers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + response_id UUID REFERENCES survey_responses(id) ON DELETE CASCADE NOT NULL, + question_id UUID REFERENCES survey_questions(id) ON DELETE CASCADE NOT NULL, + answer_text TEXT, + answer_json JSONB, -- For multiple choice answers stored as array + created_at TIMESTAMP DEFAULT now() NOT NULL, + updated_at TIMESTAMP DEFAULT now() NOT NULL +); + +-- Add performance indexes +CREATE INDEX IF NOT EXISTS idx_surveys_type_active ON surveys(survey_type, is_active); +CREATE INDEX IF NOT EXISTS idx_survey_questions_survey_order ON survey_questions(survey_id, sort_order); +CREATE INDEX IF NOT EXISTS idx_survey_responses_user_survey ON survey_responses(user_id, survey_id); +CREATE INDEX IF NOT EXISTS idx_survey_responses_completed ON survey_responses(survey_id, is_completed); +CREATE INDEX IF NOT EXISTS idx_survey_answers_response ON survey_answers(response_id); + +-- Add constraints +ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_sort_order_check CHECK (sort_order >= 0); +ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_type_check CHECK (question_type IN ('single_choice', 'multiple_choice', 'text')); + +-- Add unique constraint to prevent duplicate responses per user per survey +ALTER TABLE survey_responses ADD CONSTRAINT unique_user_survey_response UNIQUE (user_id, survey_id); + +-- Add unique constraint to prevent duplicate answers per question per response +ALTER TABLE survey_answers ADD CONSTRAINT unique_response_question_answer UNIQUE (response_id, question_id); + +-- Insert the default account setup survey +INSERT INTO surveys (name, description, survey_type, is_active) VALUES +('Account Setup Survey', 'Initial questionnaire during account setup to understand user needs', 'account_setup', true) +ON CONFLICT DO NOTHING; + +-- Get the survey ID for inserting questions +DO $$ +DECLARE + survey_uuid UUID; +BEGIN + SELECT id INTO survey_uuid FROM surveys WHERE survey_type = 'account_setup' AND name = 'Account Setup Survey' LIMIT 1; + + -- Insert survey questions + INSERT INTO survey_questions (survey_id, question_key, question_type, is_required, sort_order, options) VALUES + (survey_uuid, 'organization_type', 'single_choice', true, 1, '["freelancer", "startup", "small_medium_business", "agency", "enterprise", "other"]'), + (survey_uuid, 'user_role', 'single_choice', true, 2, '["founder_ceo", "project_manager", "software_developer", "designer", "operations", "other"]'), + (survey_uuid, 'main_use_cases', 'multiple_choice', true, 3, '["task_management", "team_collaboration", "resource_planning", "client_communication", "time_tracking", "other"]'), + (survey_uuid, 'previous_tools', 'text', false, 4, null), + (survey_uuid, 'how_heard_about', 'single_choice', false, 5, '["google_search", "twitter", "linkedin", "friend_colleague", "blog_article", "other"]') + ON CONFLICT DO NOTHING; +END $$; + +COMMIT; \ No newline at end of file diff --git a/worklenz-backend/database/migrations/user_deletion_logs.sql b/worklenz-backend/database/migrations/user_deletion_logs.sql new file mode 100644 index 000000000..5d5f9f275 --- /dev/null +++ b/worklenz-backend/database/migrations/user_deletion_logs.sql @@ -0,0 +1,29 @@ +-- Create table for tracking user deletion requests +CREATE TABLE IF NOT EXISTS user_deletion_logs ( + id UUID DEFAULT uuid_generate_v4() NOT NULL, + user_id UUID NOT NULL, + email TEXT NOT NULL, + name TEXT NOT NULL, + requested_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + scheduled_deletion_date TIMESTAMP WITH TIME ZONE NOT NULL, + deleted_at TIMESTAMP WITH TIME ZONE, + deletion_completed BOOLEAN DEFAULT FALSE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +ALTER TABLE user_deletion_logs + ADD CONSTRAINT user_deletion_logs_pk + PRIMARY KEY (id); + +ALTER TABLE user_deletion_logs + ADD CONSTRAINT user_deletion_logs_user_id_fk + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +-- Create index for faster queries +CREATE INDEX IF NOT EXISTS idx_user_deletion_logs_user_id ON user_deletion_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_user_deletion_logs_scheduled_deletion ON user_deletion_logs(scheduled_deletion_date) WHERE NOT deletion_completed; + +-- Add comment for documentation +COMMENT ON TABLE user_deletion_logs IS 'Tracks user account deletion requests and their scheduled deletion dates'; +COMMENT ON COLUMN user_deletion_logs.scheduled_deletion_date IS 'Date when the user data should be permanently deleted (30 days after request)'; +COMMENT ON COLUMN user_deletion_logs.deletion_completed IS 'Flag to indicate if the deletion process has been completed'; \ No newline at end of file diff --git a/worklenz-backend/database/sql/1_tables.sql b/worklenz-backend/database/sql/1_tables.sql index 2ab000773..7b0e3f3ce 100644 --- a/worklenz-backend/database/sql/1_tables.sql +++ b/worklenz-backend/database/sql/1_tables.sql @@ -2297,3 +2297,60 @@ ALTER TABLE organization_working_days ALTER TABLE organization_working_days ADD CONSTRAINT org_organization_id_fk FOREIGN KEY (organization_id) REFERENCES organizations; + +-- Survey tables for account setup questionnaire +CREATE TABLE IF NOT EXISTS surveys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + survey_type VARCHAR(50) DEFAULT 'account_setup' NOT NULL, + is_active BOOLEAN DEFAULT TRUE NOT NULL, + created_at TIMESTAMP DEFAULT now() NOT NULL, + updated_at TIMESTAMP DEFAULT now() NOT NULL +); + +CREATE TABLE IF NOT EXISTS survey_questions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL, + question_key VARCHAR(100) NOT NULL, + question_type VARCHAR(50) NOT NULL, + is_required BOOLEAN DEFAULT FALSE NOT NULL, + sort_order INTEGER DEFAULT 0 NOT NULL, + options JSONB, + created_at TIMESTAMP DEFAULT now() NOT NULL, + updated_at TIMESTAMP DEFAULT now() NOT NULL +); + +CREATE TABLE IF NOT EXISTS survey_responses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL, + user_id UUID REFERENCES users(id) ON DELETE CASCADE NOT NULL, + is_completed BOOLEAN DEFAULT FALSE NOT NULL, + started_at TIMESTAMP DEFAULT now() NOT NULL, + completed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT now() NOT NULL, + updated_at TIMESTAMP DEFAULT now() NOT NULL +); + +CREATE TABLE IF NOT EXISTS survey_answers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + response_id UUID REFERENCES survey_responses(id) ON DELETE CASCADE NOT NULL, + question_id UUID REFERENCES survey_questions(id) ON DELETE CASCADE NOT NULL, + answer_text TEXT, + answer_json JSONB, + created_at TIMESTAMP DEFAULT now() NOT NULL, + updated_at TIMESTAMP DEFAULT now() NOT NULL +); + +-- Survey table indexes +CREATE INDEX IF NOT EXISTS idx_surveys_type_active ON surveys(survey_type, is_active); +CREATE INDEX IF NOT EXISTS idx_survey_questions_survey_order ON survey_questions(survey_id, sort_order); +CREATE INDEX IF NOT EXISTS idx_survey_responses_user_survey ON survey_responses(user_id, survey_id); +CREATE INDEX IF NOT EXISTS idx_survey_responses_completed ON survey_responses(survey_id, is_completed); +CREATE INDEX IF NOT EXISTS idx_survey_answers_response ON survey_answers(response_id); + +-- Survey table constraints +ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_sort_order_check CHECK (sort_order >= 0); +ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_type_check CHECK (question_type IN ('single_choice', 'multiple_choice', 'text')); +ALTER TABLE survey_responses ADD CONSTRAINT unique_user_survey_response UNIQUE (user_id, survey_id); +ALTER TABLE survey_answers ADD CONSTRAINT unique_response_question_answer UNIQUE (response_id, question_id); diff --git a/worklenz-backend/database/sql/2_dml.sql b/worklenz-backend/database/sql/2_dml.sql index 1fd8074f4..5902b495d 100644 --- a/worklenz-backend/database/sql/2_dml.sql +++ b/worklenz-backend/database/sql/2_dml.sql @@ -142,3 +142,25 @@ DROP FUNCTION sys_insert_license_types(); INSERT INTO timezones (name, abbrev, utc_offset) SELECT name, abbrev, utc_offset FROM pg_timezone_names; + +-- Insert default account setup survey +INSERT INTO surveys (name, description, survey_type, is_active) VALUES +('Account Setup Survey', 'Initial questionnaire during account setup to understand user needs', 'account_setup', true) +ON CONFLICT DO NOTHING; + +-- Insert survey questions for account setup survey +DO $$ +DECLARE + survey_uuid UUID; +BEGIN + SELECT id INTO survey_uuid FROM surveys WHERE survey_type = 'account_setup' AND name = 'Account Setup Survey' LIMIT 1; + + -- Insert survey questions + INSERT INTO survey_questions (survey_id, question_key, question_type, is_required, sort_order, options) VALUES + (survey_uuid, 'organization_type', 'single_choice', true, 1, '["freelancer", "startup", "small_medium_business", "agency", "enterprise", "other"]'), + (survey_uuid, 'user_role', 'single_choice', true, 2, '["founder_ceo", "project_manager", "software_developer", "designer", "operations", "other"]'), + (survey_uuid, 'main_use_cases', 'multiple_choice', true, 3, '["task_management", "team_collaboration", "resource_planning", "client_communication", "time_tracking", "other"]'), + (survey_uuid, 'previous_tools', 'text', false, 4, null), + (survey_uuid, 'how_heard_about', 'single_choice', false, 5, '["google_search", "twitter", "linkedin", "friend_colleague", "blog_article", "other"]') + ON CONFLICT DO NOTHING; +END $$; diff --git a/worklenz-backend/database/sql/4_functions.sql b/worklenz-backend/database/sql/4_functions.sql index d2c752d23..22bc663b7 100644 --- a/worklenz-backend/database/sql/4_functions.sql +++ b/worklenz-backend/database/sql/4_functions.sql @@ -1279,71 +1279,89 @@ CREATE OR REPLACE FUNCTION deserialize_user(_id uuid) RETURNS json AS $$ DECLARE - _result JSON; - _team_id UUID; + _result JSON; BEGIN - - SELECT active_team FROM users WHERE id = _id INTO _team_id; - IF NOT EXISTS(SELECT 1 FROM notification_settings WHERE team_id = _team_id AND user_id = _id) - THEN - INSERT INTO notification_settings (popup_notifications_enabled, show_unread_items_count, user_id, team_id) - VALUES (TRUE, TRUE, _id, _team_id); - END IF; - - SELECT ROW_TO_JSON(rec) - INTO _result - FROM (SELECT users.id, - users.name, - users.email, - users.timezone_id AS timezone, - (SELECT name FROM timezones WHERE id = users.timezone_id) AS timezone_name, - users.avatar_url, - users.user_no, - users.socket_id, - users.created_at AS joined_date, - users.updated_at AS last_updated, - - (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) - FROM (SELECT description, type FROM worklenz_alerts WHERE active is TRUE) rec) AS alerts, - - (SELECT email_notifications_enabled - FROM notification_settings - WHERE user_id = users.id - AND team_id = t.id) AS email_notifications_enabled, - (CASE - WHEN is_owner(users.id, users.active_team) THEN users.setup_completed - ELSE TRUE END) AS setup_completed, - users.setup_completed AS my_setup_completed, - (is_null_or_empty(users.google_id) IS FALSE) AS is_google, - t.name AS team_name, - t.id AS team_id, - (SELECT id - FROM team_members - WHERE team_members.user_id = _id - AND team_id = users.active_team - AND active IS TRUE) AS team_member_id, - is_owner(users.id, users.active_team) AS owner, - is_admin(users.id, users.active_team) AS is_admin, - t.user_id AS owner_id, - ud.subscription_status, - (SELECT CASE - WHEN (ud.subscription_status) = 'trialing' - THEN (trial_expire_date)::DATE - WHEN (EXISTS(SELECT id FROM licensing_custom_subs WHERE user_id = t.user_id)) - THEN (SELECT end_date FROM licensing_custom_subs lcs WHERE lcs.user_id = t.user_id)::DATE - WHEN EXISTS (SELECT 1 - FROM licensing_user_subscriptions - WHERE user_id = t.user_id AND active IS TRUE) - THEN (SELECT (next_bill_date)::DATE - INTERVAL '1 day' - FROM licensing_user_subscriptions - WHERE user_id = t.user_id)::DATE - END) AS valid_till_date - FROM users - INNER JOIN teams t - ON t.id = COALESCE(users.active_team, - (SELECT id FROM teams WHERE teams.user_id = users.id LIMIT 1)) - LEFT JOIN organizations ud ON ud.user_id = t.user_id - WHERE users.id = _id) rec; + -- Optimized version using CTEs for better performance and maintainability + WITH user_team_data AS ( + SELECT + u.id, + u.name, + u.email, + u.timezone_id AS timezone, + u.avatar_url, + u.user_no, + u.socket_id, + u.created_at AS joined_date, + u.updated_at AS last_updated, + u.setup_completed AS my_setup_completed, + (is_null_or_empty(u.google_id) IS FALSE) AS is_google, + COALESCE(u.active_team, (SELECT id FROM teams WHERE user_id = u.id LIMIT 1)) AS team_id, + u.active_team + FROM users u + WHERE u.id = _id + ), + team_org_data AS ( + SELECT + utd.*, + t.name AS team_name, + t.user_id AS owner_id, + o.subscription_status, + o.license_type_id, + o.trial_expire_date + FROM user_team_data utd + INNER JOIN teams t ON t.id = utd.team_id + LEFT JOIN organizations o ON o.user_id = t.user_id + ), + notification_data AS ( + SELECT + tod.*, + COALESCE(ns.email_notifications_enabled, TRUE) AS email_notifications_enabled + FROM team_org_data tod + LEFT JOIN notification_settings ns ON (ns.user_id = tod.id AND ns.team_id = tod.team_id) + ), + alerts_data AS ( + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(alert_rec))), '[]'::JSON) AS alerts + FROM (SELECT description, type FROM worklenz_alerts WHERE active IS TRUE) alert_rec + ), + complete_user_data AS ( + SELECT + nd.*, + tz.name AS timezone_name, + slt.key AS subscription_type, + tm.id AS team_member_id, + ad.alerts, + CASE + WHEN nd.subscription_status = 'trialing' THEN nd.trial_expire_date::DATE + WHEN EXISTS(SELECT 1 FROM licensing_custom_subs WHERE user_id = nd.owner_id) + THEN (SELECT end_date FROM licensing_custom_subs WHERE user_id = nd.owner_id LIMIT 1)::DATE + WHEN EXISTS(SELECT 1 FROM licensing_user_subscriptions WHERE user_id = nd.owner_id AND active IS TRUE) + THEN (SELECT (next_bill_date)::DATE - INTERVAL '1 day' + FROM licensing_user_subscriptions + WHERE user_id = nd.owner_id AND active IS TRUE + LIMIT 1)::DATE + ELSE NULL + END AS valid_till_date, + CASE + WHEN is_owner(nd.id, nd.active_team) THEN nd.my_setup_completed + ELSE TRUE + END AS setup_completed, + is_owner(nd.id, nd.active_team) AS owner, + is_admin(nd.id, nd.active_team) AS is_admin + FROM notification_data nd + CROSS JOIN alerts_data ad + LEFT JOIN timezones tz ON tz.id = nd.timezone + LEFT JOIN sys_license_types slt ON slt.id = nd.license_type_id + LEFT JOIN team_members tm ON (tm.user_id = nd.id AND tm.team_id = nd.team_id AND tm.active IS TRUE) + ) + SELECT ROW_TO_JSON(complete_user_data.*) INTO _result FROM complete_user_data; + + -- Ensure notification settings exist using INSERT...ON CONFLICT for better concurrency + INSERT INTO notification_settings (user_id, team_id, email_notifications_enabled, popup_notifications_enabled, show_unread_items_count) + SELECT _id, + COALESCE((SELECT active_team FROM users WHERE id = _id), + (SELECT id FROM teams WHERE user_id = _id LIMIT 1)), + TRUE, TRUE, TRUE + ON CONFLICT (user_id, team_id) DO NOTHING; RETURN _result; END diff --git a/worklenz-backend/package-lock.json b/worklenz-backend/package-lock.json index 1a0f78d3f..4fffff201 100644 --- a/worklenz-backend/package-lock.json +++ b/worklenz-backend/package-lock.json @@ -33,7 +33,6 @@ "express-rate-limit": "^6.8.0", "express-session": "^1.17.3", "express-validator": "^6.15.0", - "grunt-cli": "^1.5.0", "helmet": "^6.2.0", "hpp": "^0.2.3", "http-errors": "^2.0.0", @@ -46,6 +45,7 @@ "morgan": "^1.10.0", "nanoid": "^3.3.6", "passport": "^0.7.0", + "passport-custom": "^1.1.1", "passport-google-oauth2": "^0.2.0", "passport-google-oauth20": "^2.0.0", "passport-local": "^1.0.0", @@ -73,6 +73,7 @@ "@types/compression": "^1.7.2", "@types/connect-flash": "^0.0.37", "@types/cookie-parser": "^1.4.3", + "@types/cookie-signature": "^1.1.2", "@types/cron": "^2.0.1", "@types/crypto-js": "^4.2.2", "@types/csurf": "^1.11.2", @@ -126,7 +127,7 @@ "typescript": "^4.9.5" }, "engines": { - "node": ">=16.13.0", + "node": ">=20.0.0", "npm": ">=8.11.0", "yarn": "WARNING: Please use npm package manager instead of yarn" } @@ -5445,6 +5446,16 @@ "@types/express": "*" } }, + "node_modules/@types/cookie-signature": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@types/cookie-signature/-/cookie-signature-1.1.2.tgz", + "integrity": "sha512-2OhrZV2LVnUAXklUFwuYUTokalh/dUb8rqt70OW6ByMSxYpauPZ+kfNLknX3aJyjY5iu8i3cUyoLZP9Fn37tTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -6455,30 +6466,12 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/array-each": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz", - "integrity": "sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, - "node_modules/array-slice": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", - "integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -6951,6 +6944,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -8056,15 +8050,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/detect-file": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", - "integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -8924,18 +8909,6 @@ "node": ">=6" } }, - "node_modules/expand-tilde": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", - "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", - "license": "MIT", - "dependencies": { - "homedir-polyfill": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/expect": { "version": "28.1.3", "resolved": "https://registry.npmjs.org/expect/-/expect-28.1.3.tgz", @@ -9088,12 +9061,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, "node_modules/fast-csv": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", @@ -9222,6 +9189,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -9287,46 +9255,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/findup-sync": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz", - "integrity": "sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==", - "license": "MIT", - "dependencies": { - "detect-file": "^1.0.0", - "is-glob": "^4.0.0", - "micromatch": "^4.0.2", - "resolve-dir": "^1.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fined": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fined/-/fined-1.2.0.tgz", - "integrity": "sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng==", - "license": "MIT", - "dependencies": { - "expand-tilde": "^2.0.2", - "is-plain-object": "^2.0.3", - "object.defaults": "^1.1.0", - "object.pick": "^1.2.0", - "parse-filepath": "^1.0.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/flagged-respawn": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz", - "integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -9427,27 +9355,6 @@ } } }, - "node_modules/for-in": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", - "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/for-own": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", - "integrity": "sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==", - "license": "MIT", - "dependencies": { - "for-in": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -9845,48 +9752,6 @@ "node": ">= 0.10" } }, - "node_modules/global-modules": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", - "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", - "license": "MIT", - "dependencies": { - "global-prefix": "^1.0.1", - "is-windows": "^1.0.1", - "resolve-dir": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/global-prefix": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", - "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", - "license": "MIT", - "dependencies": { - "expand-tilde": "^2.0.2", - "homedir-polyfill": "^1.0.1", - "ini": "^1.3.4", - "is-windows": "^1.0.1", - "which": "^1.2.14" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/global-prefix/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -9943,34 +9808,6 @@ "dev": true, "license": "MIT" }, - "node_modules/grunt-cli": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/grunt-cli/-/grunt-cli-1.5.0.tgz", - "integrity": "sha512-rILKAFoU0dzlf22SUfDtq2R1fosChXXlJM5j7wI6uoW8gwmXDXzbUvirlKZSYCdXl3LXFbR+8xyS+WFo+b6vlA==", - "license": "MIT", - "dependencies": { - "grunt-known-options": "~2.0.0", - "interpret": "~1.1.0", - "liftup": "~3.0.1", - "nopt": "~5.0.0", - "v8flags": "^4.0.1" - }, - "bin": { - "grunt": "bin/grunt" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/grunt-known-options": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/grunt-known-options/-/grunt-known-options-2.0.0.tgz", - "integrity": "sha512-GD7cTz0I4SAede1/+pAbmJRG44zFLPipVtdL9o3vqx9IEyb7b4/Y3s7r6ofI3CchR5GvYJ+8buCSioDv5dQLiA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -10042,18 +9879,6 @@ "dev": true, "license": "https://www.highcharts.com/license" }, - "node_modules/homedir-polyfill": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", - "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", - "license": "MIT", - "dependencies": { - "parse-passwd": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/hpp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/hpp/-/hpp-0.2.3.tgz", @@ -10263,12 +10088,6 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, - "node_modules/interpret": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz", - "integrity": "sha512-CLM8SNMDu7C5psFCn6Wg/tgpj/bKAg7hc2gWqcuR9OD5Ft9PhBpIu8PLicPeis+xDd6YX2ncI8MCA64I9tftIA==", - "license": "MIT" - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -10278,19 +10097,6 @@ "node": ">= 0.10" } }, - "node_modules/is-absolute": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", - "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", - "license": "MIT", - "dependencies": { - "is-relative": "^1.0.0", - "is-windows": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -10352,6 +10158,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10380,6 +10187,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -10392,6 +10200,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -10407,18 +10216,6 @@ "node": ">=8" } }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-promise": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", @@ -10443,18 +10240,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-relative": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", - "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", - "license": "MIT", - "dependencies": { - "is-unc-path": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -10467,27 +10252,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-unc-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", - "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", - "license": "MIT", - "dependencies": { - "unc-path-regex": "^0.1.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -10498,17 +10262,9 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -11526,15 +11282,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -11626,25 +11373,6 @@ "immediate": "~3.0.5" } }, - "node_modules/liftup": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/liftup/-/liftup-3.0.1.tgz", - "integrity": "sha512-yRHaiQDizWSzoXk3APcA71eOI/UuhEkNN9DiW2Tt44mhYzX4joFoCZlxsSOF7RyeLlfqzFLQI1ngFq3ggMPhOw==", - "license": "MIT", - "dependencies": { - "extend": "^3.0.2", - "findup-sync": "^4.0.0", - "fined": "^1.2.0", - "flagged-respawn": "^1.0.1", - "is-plain-object": "^2.0.4", - "object.map": "^1.0.1", - "rechoir": "^0.7.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -11883,18 +11611,6 @@ "dev": true, "license": "ISC" }, - "node_modules/make-iterator": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.1.tgz", - "integrity": "sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==", - "license": "MIT", - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -11905,15 +11621,6 @@ "tmpl": "1.0.5" } }, - "node_modules/map-cache": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", - "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -11971,6 +11678,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -12418,46 +12126,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object.defaults": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz", - "integrity": "sha512-c/K0mw/F11k4dEUBMW8naXUuBuhxRCfG7W+yFy8EcijU/rSmazOUd1XAEEe6bC0OuXY4HUKjTJv7xbxIMqdxrA==", - "license": "MIT", - "dependencies": { - "array-each": "^1.0.1", - "array-slice": "^1.0.0", - "for-own": "^1.0.0", - "isobject": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object.map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", - "integrity": "sha512-3+mAJu2PLfnSVGHwIWubpOFLscJANBKuB/6A4CxBstc4aqwQY0FWcsppuy4jU5GSB95yES5JHSI+33AWuS4k6w==", - "license": "MIT", - "dependencies": { - "for-own": "^1.0.0", - "make-iterator": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object.pick": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", - "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -12620,20 +12288,6 @@ "node": ">=6" } }, - "node_modules/parse-filepath": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", - "integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==", - "license": "MIT", - "dependencies": { - "is-absolute": "^1.0.0", - "map-cache": "^0.2.0", - "path-root": "^0.1.1" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -12653,15 +12307,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-passwd": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", - "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/parse-srcset": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", @@ -12695,6 +12340,18 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-custom": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz", + "integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/passport-google-oauth2": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/passport-google-oauth2/-/passport-google-oauth2-0.2.0.tgz", @@ -12800,27 +12457,6 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, - "node_modules/path-root": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", - "integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==", - "license": "MIT", - "dependencies": { - "path-root-regex": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-root-regex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", - "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-scurry": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", @@ -12968,6 +12604,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -13563,18 +13200,6 @@ "node": ">=8.10.0" } }, - "node_modules/rechoir": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", - "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", - "license": "MIT", - "dependencies": { - "resolve": "^1.9.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/redis": { "version": "4.7.1", "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", @@ -13726,19 +13351,6 @@ "node": ">=8" } }, - "node_modules/resolve-dir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", - "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", - "license": "MIT", - "dependencies": { - "expand-tilde": "^2.0.0", - "global-modules": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -14974,6 +14586,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -15494,15 +15107,6 @@ "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", "license": "MIT" }, - "node_modules/unc-path-regex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", - "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -15732,15 +15336,6 @@ "node": ">=10.12.0" } }, - "node_modules/v8flags": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-4.0.1.tgz", - "integrity": "sha512-fcRLaS4H/hrZk9hYwbdRM35D0U8IYMfEClhXxCivOojl+yTRAZH3Zy2sSy6qVCiGbV9YAtPssP6jaChqC9vPCg==", - "license": "MIT", - "engines": { - "node": ">= 10.13.0" - } - }, "node_modules/validator": { "version": "13.15.15", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", diff --git a/worklenz-backend/package.json b/worklenz-backend/package.json index d4e07de2e..1b8d140ff 100644 --- a/worklenz-backend/package.json +++ b/worklenz-backend/package.json @@ -80,6 +80,7 @@ "morgan": "^1.10.0", "nanoid": "^3.3.6", "passport": "^0.7.0", + "passport-custom": "^1.1.1", "passport-google-oauth2": "^0.2.0", "passport-google-oauth20": "^2.0.0", "passport-local": "^1.0.0", @@ -107,6 +108,7 @@ "@types/compression": "^1.7.2", "@types/connect-flash": "^0.0.37", "@types/cookie-parser": "^1.4.3", + "@types/cookie-signature": "^1.1.2", "@types/cron": "^2.0.1", "@types/crypto-js": "^4.2.2", "@types/csurf": "^1.11.2", diff --git a/worklenz-backend/src/controllers/account-deletion-controller.ts b/worklenz-backend/src/controllers/account-deletion-controller.ts new file mode 100644 index 000000000..826fe3bab --- /dev/null +++ b/worklenz-backend/src/controllers/account-deletion-controller.ts @@ -0,0 +1,221 @@ +import { IWorkLenzRequest } from "../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../interfaces/worklenz-response"; +import { ServerResponse } from "../models/server-response"; +import WorklenzControllerBase from "./worklenz-controller-base"; +import HandleExceptions from "../decorators/handle-exceptions"; +import axios from "axios"; +import { log_error } from "../shared/utils"; +import db from "../config/db"; + +export default class AccountDeletionController extends WorklenzControllerBase { + + @HandleExceptions() + public static async requestDeletion(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + try { + const { user } = req; + if (!user) { + return res.status(401).send(new ServerResponse(false, "Unauthorized")); + } + + const { userId, userEmail, userName } = req.body; + + // Verify the user is requesting their own deletion + if (userId !== user.id) { + return res.status(403).send(new ServerResponse(false, "Forbidden: You can only delete your own account")); + } + + // Get organization and team information + let organizationName = "Unknown"; + let teamName = "Unknown"; + try { + const orgQuery = ` + SELECT t.name as team_name, o.organization_name + FROM users u + LEFT JOIN teams t ON t.id = u.active_team + LEFT JOIN organizations o ON o.user_id = t.user_id + WHERE u.id = $1 + `; + const orgResult = await db.query(orgQuery, [user.id]); + if (orgResult.rows.length > 0) { + organizationName = orgResult.rows[0].organization_name || "Unknown"; + teamName = orgResult.rows[0].team_name || "Unknown"; + } + } catch (error) { + log_error("Error fetching organization info:", error); + } + + // Update user record with deletion flags + const deletionDate = new Date(); + const updateQuery = ` + UPDATE users + SET is_deleted = true, + deleted_at = $2 + WHERE id = $1 + RETURNING id, email, name + `; + + const result = await db.query(updateQuery, [userId, deletionDate]); + + if (result.rows.length === 0) { + return res.status(404).send(new ServerResponse(false, "User not found")); + } + + // Send Teams webhook notification + const teamsWebhookUrl = process.env.TEAMS_SUPPORT_WEBHOOK; + + if (!teamsWebhookUrl) { + log_error("Teams webhook URL not configured"); + // Continue with deletion even if webhook fails + } else { + const teamsMessage = { + "type": "message", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.4", + "body": [ + { + "type": "TextBlock", + "text": "⚠️ Account Deletion Request", + "weight": "Bolder", + "size": "Large", + "color": "Warning" + }, + { + "type": "TextBlock", + "text": "A user has requested to delete their account.", + "wrap": true, + "spacing": "Medium", + "color": "Default" + }, + { + "type": "FactSet", + "facts": [ + { + "title": "User Name:", + "value": userName || "Unknown" + }, + { + "title": "Email:", + "value": userEmail || "Unknown" + }, + { + "title": "Organization:", + "value": organizationName + }, + { + "title": "Team:", + "value": teamName + }, + { + "title": "User ID:", + "value": userId + }, + { + "title": "Deletion Date:", + "value": deletionDate.toISOString() + }, + { + "title": "Data Removal:", + "value": "Within 30 days" + } + ], + "spacing": "Medium" + }, + { + "type": "TextBlock", + "text": "⏰ Action Required", + "weight": "Bolder", + "size": "Medium", + "color": "Accent", + "spacing": "Large" + }, + { + "type": "TextBlock", + "text": "Please ensure all user data is properly archived and deleted within 30 days as per data retention policy.", + "wrap": true, + "color": "Default" + } + ] + } + } + ] + }; + + try { + await axios.post(teamsWebhookUrl, teamsMessage, { + headers: { + "Content-Type": "application/json" + }, + timeout: 10000 // 10 seconds timeout + }); + } catch (webhookError: any) { + log_error("Teams webhook error:", webhookError?.response?.data || webhookError.message); + // Continue with deletion even if webhook fails + } + } + + // Log the deletion request + const logQuery = ` + INSERT INTO user_deletion_logs (user_id, email, name, requested_at, scheduled_deletion_date) + VALUES ($1, $2, $3, $4, $5) + `; + + const scheduledDeletionDate = new Date(deletionDate); + scheduledDeletionDate.setDate(scheduledDeletionDate.getDate() + 30); + + try { + await db.query(logQuery, [ + userId, + userEmail, + userName, + deletionDate, + scheduledDeletionDate + ]); + } catch (logError) { + log_error("Error logging deletion request:", logError); + // Continue even if logging fails + } + + return res.status(200).send(new ServerResponse(true, "Account deletion request submitted successfully")); + + } catch (error) { + log_error("Account deletion controller error:", error); + return res.status(500).send(new ServerResponse(false, "Internal server error")); + } + } + + @HandleExceptions() + public static async cancelDeletion(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + try { + const { user } = req; + if (!user) { + return res.status(401).send(new ServerResponse(false, "Unauthorized")); + } + + // Cancel deletion by updating flags + const updateQuery = ` + UPDATE users + SET is_deleted = false, + deleted_at = NULL + WHERE id = $1 AND is_deleted = true + RETURNING id, email, name + `; + + const result = await db.query(updateQuery, [user.id]); + + if (result.rows.length === 0) { + return res.status(404).send(new ServerResponse(false, "No deletion request found")); + } + + return res.status(200).send(new ServerResponse(true, "Account deletion cancelled successfully")); + + } catch (error) { + log_error("Cancel deletion controller error:", error); + return res.status(500).send(new ServerResponse(false, "Internal server error")); + } + } +} \ 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..8ad790710 100644 --- a/worklenz-backend/src/controllers/auth-controller.ts +++ b/worklenz-backend/src/controllers/auth-controller.ts @@ -1,4 +1,6 @@ import bcrypt from "bcrypt"; +import passport from "passport"; +import {NextFunction} from "express"; import {sendResetEmail, sendResetSuccessEmail} from "../shared/email-templates"; @@ -181,4 +183,162 @@ export default class AuthController extends WorklenzControllerBase { res.status(500).send(new ServerResponse(false, null, DEFAULT_ERROR_MESSAGE)); } } + + public static googleMobileAuthPassport(req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction) { + + const mobileOptions = { + session: true, + failureFlash: true, + failWithError: false + }; + + passport.authenticate("google-mobile", mobileOptions, (err: any, user: any, info: any) => { + if (err) { + return res.status(500).send({ + done: false, + message: "Authentication failed", + body: null + }); + } + + if (!user) { + return res.status(400).send({ + done: false, + message: info?.message || "Authentication failed", + body: null + }); + } + // Log the user in (create session) + req.login(user, (loginErr) => { + if (loginErr) { + return res.status(500).send({ + done: false, + message: "Session creation failed", + body: null + }); + } + + // Add build version + user.build_v = FileConstants.getRelease(); + + // Ensure session is saved and cookie is set + req.session.save((saveErr) => { + if (saveErr) { + return res.status(500).send({ + done: false, + message: "Session save failed", + body: null + }); + } + + // Get session cookie details + const sessionName = process.env.SESSION_NAME || 'connect.sid'; + + // Return response with session info for mobile app to handle + res.setHeader('X-Session-ID', req.sessionID); + res.setHeader('X-Session-Name', sessionName); + + return res.status(200).send({ + done: true, + message: "Login successful", + user, + authenticated: true, + sessionId: req.sessionID, + sessionName: sessionName, + newSessionId: req.sessionID + }); + }); + }); // Close login callback + })(req, res, next); + } + + @HandleExceptions({logWithError: "body"}) + public static async googleMobileAuth(req: IWorkLenzRequest, res: IWorkLenzResponse) { + const {idToken} = req.body; + + if (!idToken) { + return res.status(400).send(new ServerResponse(false, null, "ID token is required")); + } + + try { + const response = await axios.get(`https://oauth2.googleapis.com/tokeninfo?id_token=${idToken}`); + const profile = response.data; + + // Validate token audience (client ID) - accept web, Android, and iOS client IDs + const allowedClientIds = [ + process.env.GOOGLE_CLIENT_ID, // Web client ID + process.env.GOOGLE_ANDROID_CLIENT_ID, // Android client ID + process.env.GOOGLE_IOS_CLIENT_ID, // iOS client ID + ].filter(Boolean); // Remove undefined values + + console.log("Token audience (aud):", profile.aud); + console.log("Allowed client IDs:", allowedClientIds); + console.log("Environment variables check:"); + console.log("- GOOGLE_CLIENT_ID:", process.env.GOOGLE_CLIENT_ID ? "Set" : "Not set"); + console.log("- GOOGLE_ANDROID_CLIENT_ID:", process.env.GOOGLE_ANDROID_CLIENT_ID ? "Set" : "Not set"); + console.log("- GOOGLE_IOS_CLIENT_ID:", process.env.GOOGLE_IOS_CLIENT_ID ? "Set" : "Not set"); + + if (!allowedClientIds.includes(profile.aud)) { + return res.status(400).send(new ServerResponse(false, null, "Invalid token audience")); + } + + // Validate token issuer + if (!["https://accounts.google.com", "accounts.google.com"].includes(profile.iss)) { + return res.status(400).send(new ServerResponse(false, null, "Invalid token issuer")); + } + + // Check token expiry + if (Date.now() >= profile.exp * 1000) { + return res.status(400).send(new ServerResponse(false, null, "Token expired")); + } + + if (!profile.email_verified) { + return res.status(400).send(new ServerResponse(false, null, "Email not verified")); + } + + // Check for existing local account + const localAccountResult = await db.query("SELECT 1 FROM users WHERE email = $1 AND password IS NOT NULL AND is_deleted IS FALSE;", [profile.email]); + if (localAccountResult.rowCount) { + return res.status(400).send(new ServerResponse(false, null, `No Google account exists for email ${profile.email}.`)); + } + + // Check if user exists + const userResult = await db.query( + "SELECT id, google_id, name, email, active_team FROM users WHERE google_id = $1 OR email = $2;", + [profile.sub, profile.email] + ); + + let user: any; + if (userResult.rowCount) { + // Existing user - login + user = userResult.rows[0]; + } else { + // New user - register + const googleUserData = { + id: profile.sub, + displayName: profile.name, + email: profile.email, + picture: profile.picture + }; + + const registerResult = await db.query("SELECT register_google_user($1) AS user;", [JSON.stringify(googleUserData)]); + user = registerResult.rows[0].user; + } + + // Create session + req.login(user, (err) => { + if (err) { + log_error(err); + return res.status(500).send(new ServerResponse(false, null, "Authentication failed")); + } + + user.build_v = FileConstants.getRelease(); + return res.status(200).send(new AuthResponse("Login Successful!", true, user, null, "User successfully logged in")); + }); + + } catch (error) { + log_error(error); + return res.status(400).send(new ServerResponse(false, null, "Invalid ID token")); + } + } } diff --git a/worklenz-backend/src/controllers/labels-controller.ts b/worklenz-backend/src/controllers/labels-controller.ts index 414c31d3a..87d282fc3 100644 --- a/worklenz-backend/src/controllers/labels-controller.ts +++ b/worklenz-backend/src/controllers/labels-controller.ts @@ -5,7 +5,7 @@ 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_PRIORITY_COLOR_ALPHA, WorklenzColorCodes} from "../shared/constants"; +import {TASK_PRIORITY_COLOR_ALPHA, WorklenzColorCodes, WorklenzColorShades} from "../shared/constants"; export default class LabelsController extends WorklenzControllerBase { @HandleExceptions() @@ -73,13 +73,44 @@ export default class LabelsController extends WorklenzControllerBase { WHERE id = $1 AND team_id = $2;`; - if (!WorklenzColorCodes.includes(req.body.color)) + if (!Object.values(WorklenzColorShades).flat().includes(req.body.color)) return res.status(400).send(new ServerResponse(false, null)); const result = await db.query(q, [req.params.id, req.user?.team_id, req.body.color]); return res.status(200).send(new ServerResponse(true, result.rows)); } + @HandleExceptions() + public static async updateLabel(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const updates = []; + const values = [req.params.id, req.user?.team_id]; + let paramIndex = 3; + + if (req.body.name) { + updates.push(`name = $${paramIndex++}`); + values.push(req.body.name); + } + + if (req.body.color) { + if (!Object.values(WorklenzColorShades).flat().includes(req.body.color)) + return res.status(400).send(new ServerResponse(false, null)); + updates.push(`color_code = $${paramIndex++}`); + values.push(req.body.color); + } + + if (updates.length === 0) { + return res.status(400).send(new ServerResponse(false, "No valid fields to update")); + } + + const q = `UPDATE team_labels + SET ${updates.join(', ')} + WHERE id = $1 + AND team_id = $2;`; + + const result = await db.query(q, values); + return res.status(200).send(new ServerResponse(true, result.rows)); + } + @HandleExceptions() public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const q = `DELETE diff --git a/worklenz-backend/src/controllers/project-members-controller.ts b/worklenz-backend/src/controllers/project-members-controller.ts index 4f20d1240..ccbeb4888 100644 --- a/worklenz-backend/src/controllers/project-members-controller.ts +++ b/worklenz-backend/src/controllers/project-members-controller.ts @@ -9,7 +9,7 @@ import {getColor} from "../shared/utils"; import TeamMembersController from "./team-members-controller"; import {checkTeamSubscriptionStatus} from "../shared/paddle-utils"; import {updateUsers} from "../shared/paddle-requests"; -import {statusExclude} from "../shared/constants"; +import {statusExclude, TRIAL_MEMBER_LIMIT} from "../shared/constants"; import {NotificationsService} from "../services/notifications/notifications.service"; export default class ProjectMembersController extends WorklenzControllerBase { @@ -118,6 +118,17 @@ export default class ProjectMembersController extends WorklenzControllerBase { return res.status(200).send(new ServerResponse(false, null, "Maximum number of life time users reached.")); } + /** + * Checks trial user team member limit + */ + if (subscriptionData.subscription_status === "trialing") { + const currentTrialMembers = parseInt(subscriptionData.current_count) || 0; + + if (currentTrialMembers + 1 > TRIAL_MEMBER_LIMIT) { + return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`)); + } + } + // if (subscriptionData.status === "trialing") break; if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") { // if (subscriptionData.subscription_status === "active") { diff --git a/worklenz-backend/src/controllers/projects-controller.ts b/worklenz-backend/src/controllers/projects-controller.ts index a350675e2..da2048325 100644 --- a/worklenz-backend/src/controllers/projects-controller.ts +++ b/worklenz-backend/src/controllers/projects-controller.ts @@ -71,7 +71,7 @@ export default class ProjectsController extends WorklenzControllerBase { return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${projectsLimit} projects.`)); } } - + const q = `SELECT create_project($1) AS project`; req.body.team_id = req.user?.team_id || null; @@ -317,65 +317,58 @@ export default class ProjectsController extends WorklenzControllerBase { @HandleExceptions() public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name"); + const search = (req.query.search || "").toString().trim(); + + let searchFilter = ""; + const params = [req.params.id, req.user?.team_id ?? null, size, offset]; + if (search) { + searchFilter = ` + AND ( + (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%' + OR (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%' + ) + `; + params.push(search); + } const q = ` - SELECT ROW_TO_JSON(rec) AS members - FROM (SELECT COUNT(*) AS total, - (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) - FROM (SELECT project_members.id, - team_member_id, - (SELECT name - FROM team_member_info_view - WHERE team_member_info_view.team_member_id = tm.id), - (SELECT email - FROM team_member_info_view - WHERE team_member_info_view.team_member_id = tm.id) AS email, - u.avatar_url, - (SELECT COUNT(*) - FROM tasks - WHERE archived IS FALSE - AND project_id = project_members.project_id - AND id IN (SELECT task_id - FROM tasks_assignees - WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count, - (SELECT COUNT(*) - FROM tasks - WHERE archived IS FALSE - AND project_id = project_members.project_id - AND id IN (SELECT task_id - FROM tasks_assignees - WHERE tasks_assignees.project_member_id = project_members.id) - AND status_id IN (SELECT id - FROM task_statuses - WHERE category_id = (SELECT id - FROM sys_task_status_categories - WHERE is_done IS TRUE))) AS completed_tasks_count, - EXISTS(SELECT email - FROM email_invitations - WHERE team_member_id = project_members.team_member_id - AND email_invitations.team_id = $2) AS pending_invitation, - (SELECT project_access_levels.name - FROM project_access_levels - WHERE project_access_levels.id = project_members.project_access_level_id) AS access, - (SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title - FROM project_members - INNER JOIN team_members tm ON project_members.team_member_id = tm.id - LEFT JOIN users u ON tm.user_id = u.id - WHERE project_id = $1 - ORDER BY ${sortField} ${sortOrder} - LIMIT $3 OFFSET $4) t) AS data - FROM project_members - WHERE project_id = $1) rec; + WITH filtered_members AS ( + SELECT project_members.id, + team_member_id, + (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS name, + (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS email, + u.avatar_url, + (SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count, + (SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id) AND status_id IN (SELECT id FROM task_statuses WHERE category_id = (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count, + EXISTS(SELECT email FROM email_invitations WHERE team_member_id = project_members.team_member_id AND email_invitations.team_id = $2) AS pending_invitation, + (SELECT project_access_levels.name FROM project_access_levels WHERE project_access_levels.id = project_members.project_access_level_id) AS access, + (SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title + FROM project_members + INNER JOIN team_members tm ON project_members.team_member_id = tm.id + LEFT JOIN users u ON tm.user_id = u.id + WHERE project_id = $1 + ${search ? searchFilter : ""} + ) + SELECT + (SELECT COUNT(*) FROM filtered_members) AS total, + (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) + FROM ( + SELECT * FROM filtered_members + ORDER BY ${sortField} ${sortOrder} + LIMIT $3 OFFSET $4 + ) t + ) AS data `; - const result = await db.query(q, [req.params.id, req.user?.team_id ?? null, size, offset]); + + const result = await db.query(q, params); const [data] = result.rows; - for (const member of data?.members.data || []) { + for (const member of data?.data || []) { member.progress = member.all_tasks_count > 0 ? ((member.completed_tasks_count / member.all_tasks_count) * 100).toFixed(0) : 0; } - return res.status(200).send(new ServerResponse(true, data?.members || this.paginatedDatasetDefaultStruct)); + return res.status(200).send(new ServerResponse(true, data || this.paginatedDatasetDefaultStruct)); } @HandleExceptions() @@ -779,7 +772,7 @@ export default class ProjectsController extends WorklenzControllerBase { let groupJoin = ""; let groupByFields = ""; let groupOrderBy = ""; - + switch (groupBy) { case "client": groupField = "COALESCE(projects.client_id::text, 'no-client')"; @@ -888,13 +881,13 @@ export default class ProjectsController extends WorklenzControllerBase { ELSE p2.updated_at END) AS updated_at FROM projects p2 ${groupJoin.replace("projects.", "p2.")} - WHERE p2.team_id = $1 + WHERE p2.team_id = $1 AND ${groupField.replace("projects.", "p2.")} = ${groupField} - ${categories.replace("projects.", "p2.")} - ${statuses.replace("projects.", "p2.")} - ${isArchived.replace("projects.", "p2.")} - ${isFavorites.replace("projects.", "p2.")} - ${filterByMember.replace("projects.", "p2.")} + ${categories.replace("projects.", "p2.")} + ${statuses.replace("projects.", "p2.")} + ${isArchived.replace("projects.", "p2.")} + ${isFavorites.replace("projects.", "p2.")} + ${filterByMember.replace("projects.", "p2.")} ${searchQuery.replace("projects.", "p2.")} ORDER BY ${innerSortField} ${sortOrder} ) project_data diff --git a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller-updated.ts b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller-updated.ts new file mode 100644 index 000000000..9d23c03d4 --- /dev/null +++ b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller-updated.ts @@ -0,0 +1,179 @@ +// Example of updated getMemberTimeSheets method with timezone support +// This shows the key changes needed to handle timezones properly + +import moment from "moment-timezone"; +import db from "../../config/db"; +import { IWorkLenzRequest } from "../../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../../interfaces/worklenz-response"; +import { ServerResponse } from "../../models/server-response"; +import { DATE_RANGES } from "../../shared/constants"; + +export async function getMemberTimeSheets(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const archived = req.query.archived === "true"; + const teams = (req.body.teams || []) as string[]; + const teamIds = teams.map(id => `'${id}'`).join(","); + const projects = (req.body.projects || []) as string[]; + const projectIds = projects.map(p => `'${p}'`).join(","); + const {billable} = req.body; + + // Get user timezone from request or database + const userTimezone = req.body.timezone || await getUserTimezone(req.user?.id || ""); + + if (!teamIds || !projectIds.length) + return res.status(200).send(new ServerResponse(true, { users: [], projects: [] })); + + const { duration, date_range } = req.body; + + // Calculate date range with timezone support + let startDate: moment.Moment; + let endDate: moment.Moment; + + if (date_range && date_range.length === 2) { + // Convert user's local dates to their timezone's start/end of day + startDate = moment.tz(date_range[0], userTimezone).startOf("day"); + endDate = moment.tz(date_range[1], userTimezone).endOf("day"); + } else if (duration === DATE_RANGES.ALL_TIME) { + const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`; + const minDateResult = await db.query(minDateQuery, []); + const minDate = minDateResult.rows[0]?.min_date; + startDate = minDate ? moment.tz(minDate, userTimezone) : moment.tz("2000-01-01", userTimezone); + endDate = moment.tz(userTimezone); + } else { + // Calculate ranges based on user's timezone + const now = moment.tz(userTimezone); + + switch (duration) { + case DATE_RANGES.YESTERDAY: + startDate = now.clone().subtract(1, "day").startOf("day"); + endDate = now.clone().subtract(1, "day").endOf("day"); + break; + case DATE_RANGES.LAST_WEEK: + startDate = now.clone().subtract(1, "week").startOf("isoWeek"); + endDate = now.clone().subtract(1, "week").endOf("isoWeek"); + break; + case DATE_RANGES.LAST_MONTH: + startDate = now.clone().subtract(1, "month").startOf("month"); + endDate = now.clone().subtract(1, "month").endOf("month"); + break; + case DATE_RANGES.LAST_QUARTER: + startDate = now.clone().subtract(3, "months").startOf("day"); + endDate = now.clone().endOf("day"); + break; + default: + startDate = now.clone().startOf("day"); + endDate = now.clone().endOf("day"); + } + } + + // Convert to UTC for database queries + const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss"); + const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss"); + + // Calculate working days in user's timezone + const totalDays = endDate.diff(startDate, "days") + 1; + let workingDays = 0; + + const current = startDate.clone(); + while (current.isSameOrBefore(endDate, "day")) { + if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) { + workingDays++; + } + current.add(1, "day"); + } + + // Updated SQL query with proper timezone handling + const billableQuery = buildBillableQuery(billable); + const archivedClause = archived ? "" : `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND user_id = '${req.user?.id}')`; + + const q = ` + WITH project_hours AS ( + SELECT + id, + COALESCE(hours_per_day, 8) as hours_per_day + FROM projects + WHERE id IN (${projectIds}) + ), + total_working_hours AS ( + SELECT + SUM(hours_per_day) * ${workingDays} as total_hours + FROM project_hours + ) + SELECT + u.id, + u.email, + tm.name, + tm.color_code, + COALESCE(SUM(twl.time_spent), 0) as logged_time, + COALESCE(SUM(twl.time_spent), 0) / 3600.0 as value, + (SELECT total_hours FROM total_working_hours) as total_working_hours, + CASE + WHEN (SELECT total_hours FROM total_working_hours) > 0 + THEN ROUND((COALESCE(SUM(twl.time_spent), 0) / 3600.0) / (SELECT total_hours FROM total_working_hours) * 100, 2) + ELSE 0 + END as utilization_percent, + ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0, 2) as utilized_hours, + ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0 - (SELECT total_hours FROM total_working_hours), 2) as over_under_utilized_hours, + '${userTimezone}' as user_timezone, + '${startDate.format("YYYY-MM-DD")}' as report_start_date, + '${endDate.format("YYYY-MM-DD")}' as report_end_date + FROM team_members tm + LEFT JOIN users u ON tm.user_id = u.id + LEFT JOIN task_work_log twl ON twl.user_id = u.id + LEFT JOIN tasks t ON twl.task_id = t.id ${billableQuery} + LEFT JOIN projects p ON t.project_id = p.id + WHERE tm.team_id IN (${teamIds}) + AND ( + twl.id IS NULL + OR ( + p.id IN (${projectIds}) + AND twl.created_at >= '${startUtc}'::TIMESTAMP + AND twl.created_at <= '${endUtc}'::TIMESTAMP + ${archivedClause} + ) + ) + GROUP BY u.id, u.email, tm.name, tm.color_code + ORDER BY logged_time DESC`; + + const result = await db.query(q, []); + + // Add timezone context to response + const response = { + data: result.rows, + timezone_info: { + user_timezone: userTimezone, + report_period: { + start: startDate.format("YYYY-MM-DD HH:mm:ss z"), + end: endDate.format("YYYY-MM-DD HH:mm:ss z"), + working_days: workingDays, + total_days: totalDays + } + } + }; + + return res.status(200).send(new ServerResponse(true, response)); +} + +async function getUserTimezone(userId: string): Promise { + const q = `SELECT tz.name as timezone + FROM users u + JOIN timezones tz ON u.timezone_id = tz.id + WHERE u.id = $1`; + const result = await db.query(q, [userId]); + return result.rows[0]?.timezone || "UTC"; +} + +function buildBillableQuery(billable: { billable: boolean; nonBillable: boolean }): string { + if (!billable) return ""; + + const { billable: isBillable, nonBillable } = billable; + + if (isBillable && nonBillable) { + return ""; + } else if (isBillable) { + return " AND tasks.billable IS TRUE"; + } else if (nonBillable) { + return " AND tasks.billable IS FALSE"; + } + + return ""; +} \ No newline at end of file diff --git a/worklenz-backend/src/controllers/reporting/reporting-controller-base-with-timezone.ts b/worklenz-backend/src/controllers/reporting/reporting-controller-base-with-timezone.ts new file mode 100644 index 000000000..1dae91476 --- /dev/null +++ b/worklenz-backend/src/controllers/reporting/reporting-controller-base-with-timezone.ts @@ -0,0 +1,140 @@ +import WorklenzControllerBase from "../worklenz-controller-base"; +import { IWorkLenzRequest } from "../../interfaces/worklenz-request"; +import db from "../../config/db"; +import moment from "moment-timezone"; +import { DATE_RANGES } from "../../shared/constants"; + +export default abstract class ReportingControllerBaseWithTimezone extends WorklenzControllerBase { + + /** + * Get the user's timezone from the database or request + * @param userId - The user ID + * @returns The user's timezone or 'UTC' as default + */ + protected static async getUserTimezone(userId: string): Promise { + const q = `SELECT tz.name as timezone + FROM users u + JOIN timezones tz ON u.timezone_id = tz.id + WHERE u.id = $1`; + const result = await db.query(q, [userId]); + return result.rows[0]?.timezone || "UTC"; + } + + /** + * Generate date range clause with timezone support + * @param key - Date range key (e.g., YESTERDAY, LAST_WEEK) + * @param dateRange - Array of date strings + * @param userTimezone - User's timezone (e.g., 'America/New_York') + * @returns SQL clause for date filtering + */ + protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string) { + // For custom date ranges + if (dateRange.length === 2) { + try { + // Handle different date formats that might come from frontend + let startDate, endDate; + + // Try to parse the date - it might be a full JS Date string or ISO string + if (dateRange[0].includes("GMT") || dateRange[0].includes("(")) { + // Parse JavaScript Date toString() format + startDate = moment(new Date(dateRange[0])); + endDate = moment(new Date(dateRange[1])); + } else { + // Parse ISO format or other formats + startDate = moment(dateRange[0]); + endDate = moment(dateRange[1]); + } + + // Convert to user's timezone and get start/end of day + const start = startDate.tz(userTimezone).startOf("day"); + const end = endDate.tz(userTimezone).endOf("day"); + + // Convert to UTC for database comparison + const startUtc = start.utc().format("YYYY-MM-DD HH:mm:ss"); + const endUtc = end.utc().format("YYYY-MM-DD HH:mm:ss"); + + if (start.isSame(end, "day")) { + // Single day selection + return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`; + } + + return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`; + } catch (error) { + console.error("Error parsing date range:", error, { dateRange, userTimezone }); + // Fallback to current date if parsing fails + const now = moment.tz(userTimezone); + const startUtc = now.clone().startOf("day").utc().format("YYYY-MM-DD HH:mm:ss"); + const endUtc = now.clone().endOf("day").utc().format("YYYY-MM-DD HH:mm:ss"); + return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`; + } + } + + // For predefined ranges, calculate based on user's timezone + const now = moment.tz(userTimezone); + let startDate, endDate; + + switch (key) { + case DATE_RANGES.YESTERDAY: + startDate = now.clone().subtract(1, "day").startOf("day"); + endDate = now.clone().subtract(1, "day").endOf("day"); + break; + case DATE_RANGES.LAST_WEEK: + startDate = now.clone().subtract(1, "week").startOf("week"); + endDate = now.clone().subtract(1, "week").endOf("week"); + break; + case DATE_RANGES.LAST_MONTH: + startDate = now.clone().subtract(1, "month").startOf("month"); + endDate = now.clone().subtract(1, "month").endOf("month"); + break; + case DATE_RANGES.LAST_QUARTER: + startDate = now.clone().subtract(3, "months").startOf("day"); + endDate = now.clone().endOf("day"); + break; + default: + return ""; + } + + if (startDate && endDate) { + const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss"); + const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss"); + return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`; + } + + return ""; + } + + /** + * Format dates for display in user's timezone + * @param date - Date to format + * @param userTimezone - User's timezone + * @param format - Moment format string + * @returns Formatted date string + */ + protected static formatDateInTimezone(date: string | Date, userTimezone: string, format = "YYYY-MM-DD HH:mm:ss") { + return moment.tz(date, userTimezone).format(format); + } + + /** + * Get working days count between two dates in user's timezone + * @param startDate - Start date + * @param endDate - End date + * @param userTimezone - User's timezone + * @returns Number of working days + */ + protected static getWorkingDaysInTimezone(startDate: string, endDate: string, userTimezone: string): number { + const start = moment.tz(startDate, userTimezone); + const end = moment.tz(endDate, userTimezone); + let workingDays = 0; + + const current = start.clone(); + while (current.isSameOrBefore(end, "day")) { + // Monday = 1, Friday = 5 + if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) { + workingDays++; + } + current.add(1, "day"); + } + + return workingDays; + } +} \ No newline at end of file diff --git a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts index 975004372..60a3da765 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts @@ -6,10 +6,69 @@ import { IWorkLenzResponse } from "../../interfaces/worklenz-response"; import { ServerResponse } from "../../models/server-response"; import { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../shared/constants"; import { formatDuration, getColor, int } from "../../shared/utils"; -import ReportingControllerBase from "./reporting-controller-base"; +import ReportingControllerBaseWithTimezone from "./reporting-controller-base-with-timezone"; import Excel from "exceljs"; -export default class ReportingMembersController extends ReportingControllerBase { +export default class ReportingMembersController extends ReportingControllerBaseWithTimezone { + + protected static getPercentage(n: number, total: number) { + return +(n ? (n / total) * 100 : 0).toFixed(); + } + + protected static getCurrentTeamId(req: IWorkLenzRequest): string | null { + return req.user?.team_id ?? null; + } + + public static convertMinutesToHoursAndMinutes(totalMinutes: number) { + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${hours}h ${minutes}m`; + } + + public static convertSecondsToHoursAndMinutes(seconds: number) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; + } + + protected static formatEndDate(endDate: string) { + const end = moment(endDate).format("YYYY-MM-DD"); + const fEndDate = moment(end); + return fEndDate; + } + + protected static formatCurrentDate() { + const current = moment().format("YYYY-MM-DD"); + const fCurrentDate = moment(current); + return fCurrentDate; + } + + protected static getDaysLeft(endDate: string): number | null { + if (!endDate) return null; + + const fCurrentDate = this.formatCurrentDate(); + const fEndDate = this.formatEndDate(endDate); + + return fEndDate.diff(fCurrentDate, "days"); + } + + protected static isOverdue(endDate: string): boolean { + if (!endDate) return false; + + const fCurrentDate = this.formatCurrentDate(); + const fEndDate = this.formatEndDate(endDate); + + return fEndDate.isBefore(fCurrentDate); + } + + protected static isToday(endDate: string): boolean { + if (!endDate) return false; + + const fCurrentDate = this.formatCurrentDate(); + const fEndDate = this.formatEndDate(endDate); + + return fEndDate.isSame(fCurrentDate); + } private static async getMembers( teamId: string, searchQuery = "", @@ -265,8 +324,8 @@ export default class ReportingMembersController extends ReportingControllerBase (SELECT color_code FROM project_phases WHERE id = (SELECT phase_id FROM task_phase WHERE task_id = t.id)) AS phase_color, (total_minutes * 60) AS total_minutes, - (SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id AND ta.team_member_id = $1) AS time_logged, - ((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id AND ta.team_member_id = $1) - (total_minutes * 60)) AS overlogged_time`; + (SELECT SUM(time_spent) FROM task_work_log twl WHERE twl.task_id = t.id AND twl.user_id = (SELECT user_id FROM team_members WHERE id = $1)) AS time_logged, + ((SELECT SUM(time_spent) FROM task_work_log twl WHERE twl.task_id = t.id AND twl.user_id = (SELECT user_id FROM team_members WHERE id = $1)) - (total_minutes * 60)) AS overlogged_time`; } protected static getActivityLogsOverdue(key: string, dateRange: string[]) { @@ -487,7 +546,9 @@ export default class ReportingMembersController extends ReportingControllerBase dateRange = date_range.split(","); } - const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "twl"); + // Get user timezone for proper date filtering + const userTimezone = await this.getUserTimezone(req.user?.id as string); + const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone); const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log"); const memberName = (req.query.member_name as string)?.trim() || null; @@ -1038,7 +1099,9 @@ export default class ReportingMembersController extends ReportingControllerBase public static async getMemberTimelogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const { team_member_id, team_id, duration, date_range, archived, billable } = req.body; - const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration || DATE_RANGES.LAST_WEEK, date_range, "twl"); + // Get user timezone for proper date filtering + const userTimezone = await this.getUserTimezone(req.user?.id as string); + const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone); const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log"); const billableQuery = this.buildBillableQuery(billable); @@ -1230,8 +1293,8 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen row.actual_time = int(row.actual_time); row.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(row.estimated_time)); row.actual_time_string = this.convertSecondsToHoursAndMinutes(int(row.actual_time)); - row.days_left = ReportingControllerBase.getDaysLeft(row.end_date); - row.is_overdue = ReportingControllerBase.isOverdue(row.end_date); + row.days_left = this.getDaysLeft(row.end_date); + row.is_overdue = this.isOverdue(row.end_date); if (row.days_left && row.is_overdue) { row.days_left = row.days_left.toString().replace(/-/g, ""); } diff --git a/worklenz-backend/src/controllers/support-controller.ts b/worklenz-backend/src/controllers/support-controller.ts new file mode 100644 index 000000000..8651edd33 --- /dev/null +++ b/worklenz-backend/src/controllers/support-controller.ts @@ -0,0 +1,123 @@ +import { IWorkLenzRequest } from "../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../interfaces/worklenz-response"; +import { ServerResponse } from "../models/server-response"; +import WorklenzControllerBase from "./worklenz-controller-base"; +import HandleExceptions from "../decorators/handle-exceptions"; +import axios from "axios"; +import { log_error } from "../shared/utils"; +import db from "../config/db"; + +export default class SupportController extends WorklenzControllerBase { + + @HandleExceptions() + public static async contactSupport(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + try { + const {user} = req; + if (!user) { + return res.status(401).send(new ServerResponse(false, "Unauthorized")); + } + + const { reason } = req.body; + + // Get user's current session data + const userEmail = user.email; + const userName = user.name; + const subscriptionType = req.body.subscription_type || "Unknown"; + + // Get organization name from teams table + let organizationName = "Unknown"; + try { + const orgQuery = ` + SELECT t.name as team_name, o.organization_name + FROM users u + LEFT JOIN teams t ON t.id = u.active_team + LEFT JOIN organizations o ON o.user_id = t.user_id + WHERE u.id = $1 + `; + const orgResult = await db.query(orgQuery, [user.id]); + if (orgResult.rows.length > 0) { + organizationName = orgResult.rows[0].organization_name || orgResult.rows[0].team_name || "Unknown"; + } + } catch (error) { + log_error("Error fetching organization info:", error); + } + + // Send Teams webhook notification + const teamsWebhookUrl = process.env.TEAMS_SUPPORT_WEBHOOK; + + if (!teamsWebhookUrl) { + log_error("Teams webhook URL not configured"); + return res.status(500).send(new ServerResponse(false, "Support notification not configured")); + } + + const teamsMessage = { + "type": "message", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.4", + "body": [ + { + "type": "TextBlock", + "text": "🔔 Custom Plan Support Request", + "weight": "Bolder", + "size": "Medium", + "color": "Accent" + }, + { + "type": "TextBlock", + "text": "A user with a custom plan has requested support assistance.", + "wrap": true, + "spacing": "Medium" + }, + { + "type": "FactSet", + "facts": [ + { + "title": "Name:", + "value": userName + }, + { + "title": "Email:", + "value": userEmail + }, + { + "title": "Organization:", + "value": organizationName + }, + { + "title": "Reason:", + "value": reason || "Custom plan renewal/support" + } + ], + "spacing": "Medium" + } + ] + } + } + ] + }; + + try { + await axios.post(teamsWebhookUrl, teamsMessage, { + headers: { + "Content-Type": "application/json" + }, + timeout: 10000 // 10 seconds timeout + }); + + return res.status(200).send(new ServerResponse(true, "Support request sent successfully")); + } catch (webhookError: any) { + log_error("Teams webhook error:", webhookError?.response?.data || webhookError.message); + return res.status(500).send(new ServerResponse(false, "Failed to send support notification")); + } + + } catch (error) { + log_error("Support controller error:", error); + return res.status(500).send(new ServerResponse(false, "Internal server error")); + } + } +} \ No newline at end of file diff --git a/worklenz-backend/src/controllers/survey-controller.ts b/worklenz-backend/src/controllers/survey-controller.ts new file mode 100644 index 000000000..cd66f97a0 --- /dev/null +++ b/worklenz-backend/src/controllers/survey-controller.ts @@ -0,0 +1,201 @@ +import { IWorkLenzRequest } from "../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../interfaces/worklenz-response"; +import { ServerResponse } from "../models/server-response"; +import WorklenzControllerBase from "./worklenz-controller-base"; +import HandleExceptions from "../decorators/handle-exceptions"; +import { ISurveySubmissionRequest } from "../interfaces/survey"; +import db from "../config/db"; + +export default class SurveyController extends WorklenzControllerBase { + @HandleExceptions() + public static async getAccountSetupSurvey(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const q = ` + SELECT + s.id, + s.name, + s.description, + s.survey_type, + s.is_active, + COALESCE( + json_agg( + json_build_object( + 'id', sq.id, + 'survey_id', sq.survey_id, + 'question_key', sq.question_key, + 'question_type', sq.question_type, + 'is_required', sq.is_required, + 'sort_order', sq.sort_order, + 'options', sq.options + ) ORDER BY sq.sort_order + ) FILTER (WHERE sq.id IS NOT NULL), + '[]' + ) AS questions + FROM surveys s + LEFT JOIN survey_questions sq ON s.id = sq.survey_id + WHERE s.survey_type = 'account_setup' AND s.is_active = true + GROUP BY s.id, s.name, s.description, s.survey_type, s.is_active + LIMIT 1; + `; + + const result = await db.query(q); + const [survey] = result.rows; + + if (!survey) { + return res.status(200).send(new ServerResponse(false, null, "Account setup survey not found")); + } + + return res.status(200).send(new ServerResponse(true, survey)); + } + + @HandleExceptions() + public static async submitSurveyResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const userId = req.user?.id; + const body = req.body as ISurveySubmissionRequest; + + if (!userId) { + return res.status(200).send(new ServerResponse(false, null, "User not authenticated")); + } + + if (!body.survey_id || !body.answers || !Array.isArray(body.answers)) { + return res.status(200).send(new ServerResponse(false, null, "Invalid survey submission data")); + } + + // Check if user has already submitted a response for this survey + const existingResponseQuery = ` + SELECT id FROM survey_responses + WHERE user_id = $1 AND survey_id = $2; + `; + const existingResult = await db.query(existingResponseQuery, [userId, body.survey_id]); + + let responseId: string; + + if (existingResult.rows.length > 0) { + // Update existing response + responseId = existingResult.rows[0].id; + + const updateResponseQuery = ` + UPDATE survey_responses + SET is_completed = true, completed_at = NOW(), updated_at = NOW() + WHERE id = $1; + `; + await db.query(updateResponseQuery, [responseId]); + + // Delete existing answers + const deleteAnswersQuery = `DELETE FROM survey_answers WHERE response_id = $1;`; + await db.query(deleteAnswersQuery, [responseId]); + } else { + // Create new response + const createResponseQuery = ` + INSERT INTO survey_responses (survey_id, user_id, is_completed, completed_at) + VALUES ($1, $2, true, NOW()) + RETURNING id; + `; + const responseResult = await db.query(createResponseQuery, [body.survey_id, userId]); + responseId = responseResult.rows[0].id; + } + + // Insert new answers + if (body.answers.length > 0) { + const answerValues: string[] = []; + const params: any[] = []; + + body.answers.forEach((answer, index) => { + const baseIndex = index * 4; + answerValues.push(`($${baseIndex + 1}, $${baseIndex + 2}, $${baseIndex + 3}, $${baseIndex + 4})`); + + params.push( + responseId, + answer.question_id, + answer.answer_text || null, + answer.answer_json ? JSON.stringify(answer.answer_json) : null + ); + }); + + const insertAnswersQuery = ` + INSERT INTO survey_answers (response_id, question_id, answer_text, answer_json) + VALUES ${answerValues.join(', ')}; + `; + + await db.query(insertAnswersQuery, params); + } + + return res.status(200).send(new ServerResponse(true, { response_id: responseId })); + } + + @HandleExceptions() + public static async getUserSurveyResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const userId = req.user?.id; + const surveyId = req.params.survey_id; + + if (!userId) { + return res.status(200).send(new ServerResponse(false, null, "User not authenticated")); + } + + const q = ` + SELECT + sr.id, + sr.survey_id, + sr.user_id, + sr.is_completed, + sr.started_at, + sr.completed_at, + COALESCE( + json_agg( + json_build_object( + 'question_id', sa.question_id, + 'answer_text', sa.answer_text, + 'answer_json', sa.answer_json + ) + ) FILTER (WHERE sa.id IS NOT NULL), + '[]' + ) AS answers + FROM survey_responses sr + LEFT JOIN survey_answers sa ON sr.id = sa.response_id + WHERE sr.user_id = $1 AND sr.survey_id = $2 + GROUP BY sr.id, sr.survey_id, sr.user_id, sr.is_completed, sr.started_at, sr.completed_at; + `; + + const result = await db.query(q, [userId, surveyId]); + const [response] = result.rows; + + if (!response) { + return res.status(200).send(new ServerResponse(false, null, "Survey response not found")); + } + + return res.status(200).send(new ServerResponse(true, response)); + } + + @HandleExceptions() + public static async checkAccountSetupSurveyStatus(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const userId = req.user?.id; + + if (!userId) { + return res.status(200).send(new ServerResponse(false, null, "User not authenticated")); + } + + const q = ` + SELECT EXISTS( + SELECT 1 + FROM survey_responses sr + INNER JOIN surveys s ON sr.survey_id = s.id + WHERE sr.user_id = $1 + AND s.survey_type = 'account_setup' + AND sr.is_completed = true + ) as is_completed, + ( + SELECT sr.completed_at + FROM survey_responses sr + INNER JOIN surveys s ON sr.survey_id = s.id + WHERE sr.user_id = $1 + AND s.survey_type = 'account_setup' + AND sr.is_completed = true + LIMIT 1 + ) as completed_at; + `; + + const result = await db.query(q, [userId]); + const status = result.rows[0] || { is_completed: false, completed_at: null }; + + return res.status(200).send(new ServerResponse(true, status)); + } +} \ No newline at end of file diff --git a/worklenz-backend/src/controllers/task-comments-controller.ts b/worklenz-backend/src/controllers/task-comments-controller.ts index 2eb210a7d..1430db552 100644 --- a/worklenz-backend/src/controllers/task-comments-controller.ts +++ b/worklenz-backend/src/controllers/task-comments-controller.ts @@ -124,7 +124,7 @@ export default class TaskCommentsController extends WorklenzControllerBase { const q = ` INSERT INTO task_comment_attachments (name, type, size, task_id, comment_id, team_id, project_id) VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, name, type, task_id, comment_id, created_at, + RETURNING id, name, type, task_id, comment_id, created_at, CONCAT($8::TEXT, '/', team_id, '/', project_id, '/', task_id, '/', comment_id, '/', id, '.', type) AS url; `; @@ -217,7 +217,49 @@ export default class TaskCommentsController extends WorklenzControllerBase { } } - return res.status(200).send(new ServerResponse(true, data.comment)); + // Get user avatar URL from database + const avatarQuery = `SELECT avatar_url FROM users WHERE id = $1`; + const avatarResult = await db.query(avatarQuery, [req.user?.id]); + const avatarUrl = avatarResult.rows[0]?.avatar_url || ""; + + // Get comment details including created_at + const commentQuery = `SELECT created_at FROM task_comments WHERE id = $1`; + const commentResult = await db.query(commentQuery, [response.id]); + const commentData = commentResult.rows[0]; + + // Get attachments if any + const attachmentsQuery = `SELECT id, name, type, size FROM task_comment_attachments WHERE comment_id = $1`; + const attachmentsResult = await db.query(attachmentsQuery, [response.id]); + const commentAttachments = attachmentsResult.rows.map(att => ({ + id: att.id, + name: att.name, + type: att.type, + size: att.size + })); + + + const commentdata = { + attachments: commentAttachments, + avatar_url: avatarUrl, + content: req.body.content, + created_at: commentData?.created_at || new Date().toISOString(), + edit: false, + id: response.id, + member_name: req.user?.name || "", + mentions: mentions || [], + rawContent: req.body.content, + reactions: { + likes: { + count: 0, + liked_members: [], + liked_member_ids: [] + } + }, + team_member_id: req.user?.team_member_id || "", + user_id: req.user?.id || "" + }; + + return res.status(200).send(new ServerResponse(true, commentdata)); } @HandleExceptions() @@ -530,17 +572,17 @@ export default class TaskCommentsController extends WorklenzControllerBase { for (const attachment of attachments) { if (req.user?.subscription_status === "free" && req.user?.owner_id) { const limits = await getFreePlanSettings(); - + const usedStorage = await getUsedStorage(req.user?.owner_id); if ((parseInt(usedStorage) + attachment.size) > megabytesToBytes(parseInt(limits.free_tier_storage))) { return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot exceed ${limits.free_tier_storage}MB of storage.`)); } } - + const q = ` INSERT INTO task_comment_attachments (name, type, size, task_id, comment_id, team_id, project_id) VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, name, type, task_id, comment_id, created_at, + RETURNING id, name, type, task_id, comment_id, created_at, CONCAT($8::TEXT, '/', team_id, '/', project_id, '/', task_id, '/', comment_id, '/', id, '.', type) AS url; `; diff --git a/worklenz-backend/src/controllers/team-members-controller.ts b/worklenz-backend/src/controllers/team-members-controller.ts index 33bde00d0..a338cc73a 100644 --- a/worklenz-backend/src/controllers/team-members-controller.ts +++ b/worklenz-backend/src/controllers/team-members-controller.ts @@ -13,7 +13,7 @@ import { SocketEvents } from "../socket.io/events"; import WorklenzControllerBase from "./worklenz-controller-base"; import HandleExceptions from "../decorators/handle-exceptions"; import { formatDuration, getColor } from "../shared/utils"; -import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA } from "../shared/constants"; +import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA, TRIAL_MEMBER_LIMIT } from "../shared/constants"; import { checkTeamSubscriptionStatus } from "../shared/paddle-utils"; import { updateUsers } from "../shared/paddle-requests"; import { NotificationsService } from "../services/notifications/notifications.service"; @@ -141,6 +141,17 @@ export default class TeamMembersController extends WorklenzControllerBase { return res.status(200).send(new ServerResponse(false, null, "Cannot exceed the maximum number of life time users.")); } + /** + * Checks trial user team member limit + */ + if (subscriptionData.subscription_status === "trialing") { + const currentTrialMembers = parseInt(subscriptionData.current_count) || 0; + + if (currentTrialMembers + incrementBy > TRIAL_MEMBER_LIMIT) { + return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`)); + } + } + /** * Checks subscription details and updates the user count if applicable. * Sends a response if there is an issue with the subscription. @@ -1081,6 +1092,18 @@ export default class TeamMembersController extends WorklenzControllerBase { return res.status(200).send(new ServerResponse(false, "Please check your subscription status.")); } + /** + * Checks trial user team member limit + */ + if (subscriptionData.subscription_status === "trialing") { + const currentTrialMembers = parseInt(subscriptionData.current_count) || 0; + const emailsToAdd = req.body.emails?.length || 1; + + if (currentTrialMembers + emailsToAdd > TRIAL_MEMBER_LIMIT) { + return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`)); + } + } + // if (subscriptionData.status === "trialing") break; if (!subscriptionData.is_credit && !subscriptionData.is_custom) { if (subscriptionData.subscription_status === "active") { diff --git a/worklenz-backend/src/controllers/user-activity-logs-controller.ts b/worklenz-backend/src/controllers/user-activity-logs-controller.ts new file mode 100644 index 000000000..eeacb4b46 --- /dev/null +++ b/worklenz-backend/src/controllers/user-activity-logs-controller.ts @@ -0,0 +1,117 @@ +import moment from "moment"; +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 { formatDuration, formatLogText, getColor } from "../shared/utils"; + +interface IUserRecentTask { + task_id: string; + task_name: string; + project_id: string; + project_name: string; + last_activity_at: string; + activity_count: number; + project_color?: string; + task_status?: string; + status_color?: string; +} + +interface IUserTimeLoggedTask { + task_id: string; + task_name: string; + project_id: string; + project_name: string; + total_time_logged: number; + total_time_logged_string: string; + last_logged_at: string; + logged_by_timer: boolean; + project_color?: string; + task_status?: string; + status_color?: string; + log_entries_count?: number; + estimated_time?: number; +} + +export default class UserActivityLogsController extends WorklenzControllerBase { + @HandleExceptions() + public static async getRecentTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + if (!req.user) { + return res.status(401).send(new ServerResponse(false, null, "Unauthorized")); + } + + const { id: userId, team_id: teamId } = req.user; + const { offset = 0, limit = 10 } = req.query; + + // Optimized query with better performance and team filtering + const q = ` + SELECT DISTINCT tal.task_id, t.name AS task_name, tal.project_id, p.name AS project_name, + MAX(tal.created_at) AS last_activity_at, + COUNT(DISTINCT tal.id) AS activity_count, + p.color_code AS project_color, + (SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status, + (SELECT color_code + FROM sys_task_status_categories + WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color + FROM task_activity_logs tal + INNER JOIN tasks t ON tal.task_id = t.id AND t.archived = FALSE + INNER JOIN projects p ON tal.project_id = p.id AND p.team_id = $1 + WHERE tal.user_id = $2 + AND tal.created_at >= NOW() - INTERVAL '30 days' + GROUP BY tal.task_id, t.name, tal.project_id, p.name, p.color_code, t.status_id + ORDER BY MAX(tal.created_at) DESC + LIMIT $3 OFFSET $4; + `; + + const result = await db.query(q, [teamId, userId, limit, offset]); + const tasks: IUserRecentTask[] = result.rows; + + return res.status(200).send(new ServerResponse(true, tasks)); + } + + @HandleExceptions() + public static async getTimeLoggedTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + if (!req.user) { + return res.status(401).send(new ServerResponse(false, null, "Unauthorized")); + } + + const { id: userId, team_id: teamId } = req.user; + const { offset = 0, limit = 10 } = req.query; + + // Optimized query with better performance, team filtering, and useful additional data + const q = ` + SELECT twl.task_id, t.name AS task_name, t.project_id, p.name AS project_name, + SUM(twl.time_spent) AS total_time_logged, + MAX(twl.created_at) AS last_logged_at, + MAX(twl.logged_by_timer::int)::boolean AS logged_by_timer, + p.color_code AS project_color, + (SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status, + (SELECT color_code + FROM sys_task_status_categories + WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color, + COUNT(DISTINCT twl.id) AS log_entries_count, + (t.total_minutes * 60) AS estimated_time + FROM task_work_log twl + INNER JOIN tasks t ON twl.task_id = t.id AND t.archived = FALSE + INNER JOIN projects p ON t.project_id = p.id AND p.team_id = $1 + WHERE twl.user_id = $2 + AND twl.created_at >= NOW() - INTERVAL '90 days' + GROUP BY twl.task_id, t.name, t.project_id, p.name, p.color_code, t.status_id, t.total_minutes + HAVING SUM(twl.time_spent) > 0 + ORDER BY MAX(twl.created_at) DESC + LIMIT $3 OFFSET $4; + `; + + const result = await db.query(q, [teamId, userId, limit, offset]); + const tasks: IUserTimeLoggedTask[] = result.rows.map(task => ({ + ...task, + total_time_logged_string: formatDuration(moment.duration(task.total_time_logged, "seconds")), + })); + + return res.status(200).send(new ServerResponse(true, tasks)); + } +} diff --git a/worklenz-backend/src/interfaces/survey.ts b/worklenz-backend/src/interfaces/survey.ts new file mode 100644 index 000000000..8cb3f5a95 --- /dev/null +++ b/worklenz-backend/src/interfaces/survey.ts @@ -0,0 +1,37 @@ +export interface ISurveyQuestion { + id: string; + survey_id: string; + question_key: string; + question_type: 'single_choice' | 'multiple_choice' | 'text'; + is_required: boolean; + sort_order: number; + options?: string[]; +} + +export interface ISurvey { + id: string; + name: string; + description?: string; + survey_type: 'account_setup' | 'onboarding' | 'feedback'; + is_active: boolean; + questions?: ISurveyQuestion[]; +} + +export interface ISurveyAnswer { + question_id: string; + answer_text?: string; + answer_json?: string[]; +} + +export interface ISurveyResponse { + id?: string; + survey_id: string; + user_id?: string; + is_completed: boolean; + answers: ISurveyAnswer[]; +} + +export interface ISurveySubmissionRequest { + survey_id: string; + answers: ISurveyAnswer[]; +} \ No newline at end of file diff --git a/worklenz-backend/src/middlewares/session-middleware.ts b/worklenz-backend/src/middlewares/session-middleware.ts index cb6cd624a..d4394cb77 100644 --- a/worklenz-backend/src/middlewares/session-middleware.ts +++ b/worklenz-backend/src/middlewares/session-middleware.ts @@ -1,11 +1,13 @@ import session from "express-session"; import db from "../config/db"; import { isProduction } from "../shared/utils"; +import * as cookieSignature from "cookie-signature"; +import { randomBytes } from "crypto"; // eslint-disable-next-line @typescript-eslint/no-var-requires const pgSession = require("connect-pg-simple")(session); -export default session({ +const sessionConfig = { name: process.env.SESSION_NAME, secret: process.env.SESSION_SECRET || "development-secret-key", proxy: false, @@ -18,10 +20,56 @@ export default session({ }), cookie: { path: "/", - // secure: isProduction(), - // httpOnly: isProduction(), - // sameSite: "none", - // domain: isProduction() ? ".worklenz.com" : undefined, + httpOnly: true, + // For mobile app support in production, use "none", for local development use "lax" + sameSite: "lax" as const, + // Secure only in production (HTTPS required for sameSite: "none") + secure: false, + domain: undefined, maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days + }, + // Custom session ID handling for mobile apps + genid: () => { + return randomBytes(24).toString("base64url"); } -}); \ No newline at end of file +}; + +const sessionMiddleware = session(sessionConfig); + +// Enhanced session middleware that supports both cookies and headers for mobile apps +export default (req: any, res: any, next: any) => { + // Check if mobile app is sending session ID via header (fallback for cookie issues) + const headerSessionId = req.headers["x-session-id"]; + const headerSessionName = req.headers["x-session-name"]; + + // Only process headers if they exist AND there's no existing valid session cookie + if (headerSessionId && headerSessionName) { + const secret = process.env.SESSION_SECRET || "development-secret-key"; + + try { + // Create a signed cookie using the session secret + const signedSessionId = `s:${cookieSignature.sign(headerSessionId, secret)}`; + const encodedSignedId = encodeURIComponent(signedSessionId); + const sessionCookie = `${headerSessionName}=${encodedSignedId}`; + + if (req.headers.cookie) { + // Replace existing session cookie while keeping other cookies + req.headers.cookie = req.headers.cookie + .split(";") + .filter((cookie: string) => !cookie.trim().startsWith(headerSessionName)) + .concat(sessionCookie) + .join(";"); + } else { + // Set the session cookie from header + req.headers.cookie = sessionCookie; + } + } catch (error) { + // Fallback to the old method + const sessionCookie = `${headerSessionName}=s%3A${headerSessionId}`; + req.headers.cookie = sessionCookie; + } + } + + // Always call the original session middleware (handles both cookie and header-converted cases) + sessionMiddleware(req, res, next); +}; \ No newline at end of file diff --git a/worklenz-backend/src/middlewares/validators/password-validator.ts b/worklenz-backend/src/middlewares/validators/password-validator.ts index cef9c49fa..52ef7722f 100644 --- a/worklenz-backend/src/middlewares/validators/password-validator.ts +++ b/worklenz-backend/src/middlewares/validators/password-validator.ts @@ -8,7 +8,7 @@ import {PASSWORD_POLICY} from "../../shared/constants"; function isStrongPassword(password: string) { if (!isProduction()) return true; const strength = PasswordStrengthChecker.validate(password); - return strength.value >= 2 && strength.length < 32; + return strength.value >= 2 && strength.length <= 32; } export default function (req: Request, res: Response, next: NextFunction) { diff --git a/worklenz-backend/src/middlewares/validators/survey-submission-validator.ts b/worklenz-backend/src/middlewares/validators/survey-submission-validator.ts new file mode 100644 index 000000000..bbeefcee4 --- /dev/null +++ b/worklenz-backend/src/middlewares/validators/survey-submission-validator.ts @@ -0,0 +1,53 @@ +import { NextFunction } from "express"; +import { IWorkLenzRequest } from "../../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../../interfaces/worklenz-response"; +import { ServerResponse } from "../../models/server-response"; +import { ISurveySubmissionRequest } from "../../interfaces/survey"; + +export default function surveySubmissionValidator(req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction): IWorkLenzResponse | void { + const body = req.body as ISurveySubmissionRequest; + + if (!body) { + return res.status(200).send(new ServerResponse(false, null, "Request body is required")); + } + + if (!body.survey_id || typeof body.survey_id !== 'string') { + return res.status(200).send(new ServerResponse(false, null, "Survey ID is required and must be a string")); + } + + if (!body.answers || !Array.isArray(body.answers)) { + return res.status(200).send(new ServerResponse(false, null, "Answers are required and must be an array")); + } + + // Validate each answer + for (let i = 0; i < body.answers.length; i++) { + const answer = body.answers[i]; + + if (!answer.question_id || typeof answer.question_id !== 'string') { + return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: Question ID is required and must be a string`)); + } + + // answer_text and answer_json are both optional - users can submit empty answers + + // Validate answer_text if provided + if (answer.answer_text && typeof answer.answer_text !== 'string') { + return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_text must be a string`)); + } + + // Validate answer_json if provided + if (answer.answer_json && !Array.isArray(answer.answer_json)) { + return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_json must be an array`)); + } + + // Validate answer_json items are strings + if (answer.answer_json) { + for (let j = 0; j < answer.answer_json.length; j++) { + if (typeof answer.answer_json[j] !== 'string') { + return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_json items must be strings`)); + } + } + } + } + + return next(); +} \ No newline at end of file diff --git a/worklenz-backend/src/passport/index.ts b/worklenz-backend/src/passport/index.ts index 6c3575ae2..f41a66df5 100644 --- a/worklenz-backend/src/passport/index.ts +++ b/worklenz-backend/src/passport/index.ts @@ -4,6 +4,7 @@ import {deserialize} from "./deserialize"; import {serialize} from "./serialize"; import GoogleLogin from "./passport-strategies/passport-google"; +import GoogleMobileLogin from "./passport-strategies/passport-google-mobile"; import LocalLogin from "./passport-strategies/passport-local-login"; import LocalSignup from "./passport-strategies/passport-local-signup"; @@ -15,6 +16,7 @@ export default (passport: PassportStatic) => { passport.use("local-login", LocalLogin); passport.use("local-signup", LocalSignup); passport.use(GoogleLogin); + passport.use("google-mobile", GoogleMobileLogin); passport.serializeUser(serialize); passport.deserializeUser(deserialize); }; diff --git a/worklenz-backend/src/passport/passport-strategies/passport-google-mobile.ts b/worklenz-backend/src/passport/passport-strategies/passport-google-mobile.ts new file mode 100644 index 000000000..236334727 --- /dev/null +++ b/worklenz-backend/src/passport/passport-strategies/passport-google-mobile.ts @@ -0,0 +1,110 @@ +import { Strategy as CustomStrategy } from "passport-custom"; +import axios from "axios"; +import { Request } from "express"; +import db from "../../config/db"; +import { log_error } from "../../shared/utils"; +import { ERROR_KEY } from "./passport-constants"; + +interface GoogleTokenProfile { + sub: string; + email: string; + name: string; + picture: string; + email_verified: boolean; + aud: string; + iss: string; + exp: number; +} + +async function handleMobileGoogleAuth(req: Request, done: any) { + try { + const { idToken } = req.body; + + if (!idToken) { + return done(null, false, { message: "ID token is required" }); + } + + // Verify Google ID token + const response = await axios.get( + `https://oauth2.googleapis.com/tokeninfo?id_token=${idToken}` + ); + const profile: GoogleTokenProfile = response.data; + + // Validate token audience (client ID) + const allowedClientIds = [ + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_ANDROID_CLIENT_ID, + process.env.GOOGLE_IOS_CLIENT_ID, + ].filter(Boolean); + + if (!allowedClientIds.includes(profile.aud)) { + return done(null, false, { message: "Invalid token audience" }); + } + + // Validate token issuer + if ( + !["https://accounts.google.com", "accounts.google.com"].includes( + profile.iss + ) + ) { + return done(null, false, { message: "Invalid token issuer" }); + } + + // Check token expiry + if (Date.now() >= profile.exp * 1000) { + return done(null, false, { message: "Token expired" }); + } + + if (!profile.email_verified) { + return done(null, false, { message: "Email not verified" }); + } + + // Check for existing local account + const localAccountResult = await db.query( + "SELECT 1 FROM users WHERE email = $1 AND password IS NOT NULL AND is_deleted IS FALSE;", + [profile.email] + ); + + if (localAccountResult.rowCount) { + const message = `No Google account exists for email ${profile.email}.`; + return done(null, false, { message }); + } + + // Check if user exists + const userResult = await db.query( + "SELECT id, google_id, name, email, active_team FROM users WHERE google_id = $1 OR email = $2;", + [profile.sub, profile.email] + ); + + if (userResult.rowCount) { + // Existing user - login + const user = userResult.rows[0]; + return done(null, user, { message: "User successfully logged in" }); + } + // New user - register + const googleUserData = { + id: profile.sub, + displayName: profile.name, + email: profile.email, + picture: profile.picture, + }; + + const registerResult = await db.query( + "SELECT register_google_user($1) AS user;", + [JSON.stringify(googleUserData)] + ); + const { user } = registerResult.rows[0]; + + return done(null, user, { + message: "User successfully registered and logged in", + }); + } catch (error: any) { + log_error(error); + if (error.response?.status === 400) { + return done(null, false, { message: "Invalid ID token" }); + } + return done(error); + } +} + +export default new CustomStrategy(handleMobileGoogleAuth); 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..fddad7f55 100644 --- a/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts +++ b/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts @@ -20,6 +20,17 @@ async function isGoogleAccountFound(email: string) { return !!result.rowCount; } +async function isAccountDeactivated(email: string) { + const q = ` + SELECT 1 + FROM users + WHERE email = $1 + AND is_deleted = TRUE; + `; + const result = await db.query(q, [email]); + return !!result.rowCount; +} + async function registerUser(password: string, team_id: string, name: string, team_name: string, email: string, timezone: string, team_member_id: string) { const salt = bcrypt.genSaltSync(10); const encryptedPassword = bcrypt.hashSync(password, salt); @@ -53,6 +64,10 @@ async function handleSignUp(req: Request, email: string, password: string, done: if (googleAccountFound) return done(null, null, req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`)); + const accountDeactivated = await isAccountDeactivated(email); + if (accountDeactivated) + return done(null, null, req.flash(ERROR_KEY, `Account for email ${email} has been deactivated. Please contact support to reactivate your account.`)); + try { const user = await registerUser(password, team_id, name, team_name, email, timezone, team_member_id); sendWelcomeEmail(email, name); @@ -70,6 +85,7 @@ async function handleSignUp(req: Request, email: string, password: string, done: return done(null, null, req.flash(ERROR_KEY, `Worklenz account already exists for email ${value}.`)); } + 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.`)); diff --git a/worklenz-backend/src/public/locales/alb/task-list-filters.json b/worklenz-backend/src/public/locales/alb/task-list-filters.json index c31564982..595801b1c 100644 --- a/worklenz-backend/src/public/locales/alb/task-list-filters.json +++ b/worklenz-backend/src/public/locales/alb/task-list-filters.json @@ -81,5 +81,12 @@ "delete": "Fshi", "enterStatusName": "Shkruani emrin e statusit", "selectCategory": "Zgjidh kategorinë", - "close": "Mbyll" + "close": "Mbyll", + "clearSort": "Pastro Renditjen", + "sortAscending": "Rendit në Rritje", + "sortDescending": "Rendit në Zbritje", + "sortByField": "Rendit sipas {{field}}", + "ascendingOrder": "Rritës", + "descendingOrder": "Zbritës", + "currentSort": "Renditja aktuale: {{field}} {{order}}" } diff --git a/worklenz-backend/src/public/locales/de/task-list-filters.json b/worklenz-backend/src/public/locales/de/task-list-filters.json index 0854c34ff..b298139c4 100644 --- a/worklenz-backend/src/public/locales/de/task-list-filters.json +++ b/worklenz-backend/src/public/locales/de/task-list-filters.json @@ -81,5 +81,12 @@ "delete": "Löschen", "enterStatusName": "Statusnamen eingeben", "selectCategory": "Kategorie auswählen", - "close": "Schließen" + "close": "Schließen", + "clearSort": "Sortierung löschen", + "sortAscending": "Aufsteigend sortieren", + "sortDescending": "Absteigend sortieren", + "sortByField": "Sortieren nach {{field}}", + "ascendingOrder": "Aufsteigend", + "descendingOrder": "Absteigend", + "currentSort": "Aktuelle Sortierung: {{field}} {{order}}" } diff --git a/worklenz-backend/src/public/locales/en/account-setup.json b/worklenz-backend/src/public/locales/en/account-setup.json index 5e71ca40b..4310e0c69 100644 --- a/worklenz-backend/src/public/locales/en/account-setup.json +++ b/worklenz-backend/src/public/locales/en/account-setup.json @@ -1,7 +1,7 @@ { "continue": "Continue", - "setupYourAccount": "Setup Your Worklenz Account.", + "setupYourAccount": "Setup Your Account.", "organizationStepTitle": "Name Your Organization", "organizationStepLabel": "Pick a name for your Worklenz account.", diff --git a/worklenz-backend/src/public/locales/en/task-list-filters.json b/worklenz-backend/src/public/locales/en/task-list-filters.json index a38356c6a..6fa2ce3c4 100644 --- a/worklenz-backend/src/public/locales/en/task-list-filters.json +++ b/worklenz-backend/src/public/locales/en/task-list-filters.json @@ -81,5 +81,12 @@ "delete": "Delete", "enterStatusName": "Enter status name", "selectCategory": "Select category", - "close": "Close" + "close": "Close", + "clearSort": "Clear Sort", + "sortAscending": "Sort Ascending", + "sortDescending": "Sort Descending", + "sortByField": "Sort by {{field}}", + "ascendingOrder": "Ascending", + "descendingOrder": "Descending", + "currentSort": "Current sort: {{field}} {{order}}" } diff --git a/worklenz-backend/src/public/locales/es/task-list-filters.json b/worklenz-backend/src/public/locales/es/task-list-filters.json index 465368f05..8ee72c45f 100644 --- a/worklenz-backend/src/public/locales/es/task-list-filters.json +++ b/worklenz-backend/src/public/locales/es/task-list-filters.json @@ -77,5 +77,12 @@ "delete": "Eliminar", "enterStatusName": "Introducir nombre del estado", "selectCategory": "Seleccionar categoría", - "close": "Cerrar" + "close": "Cerrar", + "clearSort": "Limpiar Ordenamiento", + "sortAscending": "Ordenar Ascendente", + "sortDescending": "Ordenar Descendente", + "sortByField": "Ordenar por {{field}}", + "ascendingOrder": "Ascendente", + "descendingOrder": "Descendente", + "currentSort": "Ordenamiento actual: {{field}} {{order}}" } diff --git a/worklenz-backend/src/public/locales/pt/task-list-filters.json b/worklenz-backend/src/public/locales/pt/task-list-filters.json index 21e8806b5..4464c2c12 100644 --- a/worklenz-backend/src/public/locales/pt/task-list-filters.json +++ b/worklenz-backend/src/public/locales/pt/task-list-filters.json @@ -78,5 +78,12 @@ "delete": "Excluir", "enterStatusName": "Digite o nome do status", "selectCategory": "Selecionar categoria", - "close": "Fechar" + "close": "Fechar", + "clearSort": "Limpar Ordenação", + "sortAscending": "Ordenar Crescente", + "sortDescending": "Ordenar Decrescente", + "sortByField": "Ordenar por {{field}}", + "ascendingOrder": "Crescente", + "descendingOrder": "Decrescente", + "currentSort": "Ordenação atual: {{field}} {{order}}" } diff --git a/worklenz-backend/src/public/locales/zh/task-list-filters.json b/worklenz-backend/src/public/locales/zh/task-list-filters.json index 84387509e..95a4f1660 100644 --- a/worklenz-backend/src/public/locales/zh/task-list-filters.json +++ b/worklenz-backend/src/public/locales/zh/task-list-filters.json @@ -75,5 +75,12 @@ "delete": "删除", "enterStatusName": "输入状态名称", "selectCategory": "选择类别", - "close": "关闭" + "close": "关闭", + "clearSort": "清除排序", + "sortAscending": "升序排列", + "sortDescending": "降序排列", + "sortByField": "按{{field}}排序", + "ascendingOrder": "升序", + "descendingOrder": "降序", + "currentSort": "当前排序:{{field}} {{order}}" } \ No newline at end of file diff --git a/worklenz-backend/src/routes/apis/account-api-router.ts b/worklenz-backend/src/routes/apis/account-api-router.ts new file mode 100644 index 000000000..4144d7874 --- /dev/null +++ b/worklenz-backend/src/routes/apis/account-api-router.ts @@ -0,0 +1,9 @@ +import express from "express"; +import AccountDeletionController from "../../controllers/account-deletion-controller"; + +const accountApiRouter = express.Router(); + +accountApiRouter.post("/deletion-request", AccountDeletionController.requestDeletion); +accountApiRouter.post("/cancel-deletion", AccountDeletionController.cancelDeletion); + +export default accountApiRouter; \ No newline at end of file diff --git a/worklenz-backend/src/routes/apis/index.ts b/worklenz-backend/src/routes/apis/index.ts index 5a2019c86..bb55d41fc 100644 --- a/worklenz-backend/src/routes/apis/index.ts +++ b/worklenz-backend/src/routes/apis/index.ts @@ -1,120 +1,129 @@ -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 surveyApiRouter from "./survey-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 userActivityLogsApiRouter from "./user-activity-logs-api-router"; +import supportApiRouter from "./support-api-router"; +import accountApiRouter from "./account-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("/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.use("/surveys", surveyApiRouter); + +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); +api.use("/support", supportApiRouter); +api.use("/account", accountApiRouter); -export default api; +api.use("/logs", userActivityLogsApiRouter); +export default api; diff --git a/worklenz-backend/src/routes/apis/labels-api-router.ts b/worklenz-backend/src/routes/apis/labels-api-router.ts index 8f6930c22..57b395f72 100644 --- a/worklenz-backend/src/routes/apis/labels-api-router.ts +++ b/worklenz-backend/src/routes/apis/labels-api-router.ts @@ -11,6 +11,7 @@ labelsApiRouter.get("/", safeControllerFunction(LabelsController.get)); labelsApiRouter.get("/tasks/:id", idParamValidator, safeControllerFunction(LabelsController.getByTask)); labelsApiRouter.get("/project/:id", idParamValidator, safeControllerFunction(LabelsController.getByProject)); labelsApiRouter.put("/tasks/:id", idParamValidator, teamOwnerOrAdminValidator, safeControllerFunction(LabelsController.updateColor)); +labelsApiRouter.put("/team/:id", idParamValidator, teamOwnerOrAdminValidator, safeControllerFunction(LabelsController.updateLabel)); labelsApiRouter.delete("/team/:id", idParamValidator, teamOwnerOrAdminValidator, safeControllerFunction(LabelsController.deleteById)); export default labelsApiRouter; diff --git a/worklenz-backend/src/routes/apis/support-api-router.ts b/worklenz-backend/src/routes/apis/support-api-router.ts new file mode 100644 index 000000000..50931ce6b --- /dev/null +++ b/worklenz-backend/src/routes/apis/support-api-router.ts @@ -0,0 +1,8 @@ +import express from "express"; +import SupportController from "../../controllers/support-controller"; + +const supportApiRouter = express.Router(); + +supportApiRouter.post("/contact", SupportController.contactSupport); + +export default supportApiRouter; \ No newline at end of file diff --git a/worklenz-backend/src/routes/apis/survey-api-router.ts b/worklenz-backend/src/routes/apis/survey-api-router.ts new file mode 100644 index 000000000..b068294b4 --- /dev/null +++ b/worklenz-backend/src/routes/apis/survey-api-router.ts @@ -0,0 +1,20 @@ +import express from "express"; +import SurveyController from "../../controllers/survey-controller"; +import surveySubmissionValidator from "../../middlewares/validators/survey-submission-validator"; +import safeControllerFunction from "../../shared/safe-controller-function"; + +const surveyApiRouter = express.Router(); + +// Get account setup survey with questions +surveyApiRouter.get("/account-setup", safeControllerFunction(SurveyController.getAccountSetupSurvey)); + +// Check if user has completed account setup survey +surveyApiRouter.get("/account-setup/status", safeControllerFunction(SurveyController.checkAccountSetupSurveyStatus)); + +// Submit survey response +surveyApiRouter.post("/responses", surveySubmissionValidator, safeControllerFunction(SurveyController.submitSurveyResponse)); + +// Get user's survey response for a specific survey +surveyApiRouter.get("/responses/:survey_id", safeControllerFunction(SurveyController.getUserSurveyResponse)); + +export default surveyApiRouter; \ No newline at end of file diff --git a/worklenz-backend/src/routes/apis/user-activity-logs-api-router.ts b/worklenz-backend/src/routes/apis/user-activity-logs-api-router.ts new file mode 100644 index 000000000..b6f079308 --- /dev/null +++ b/worklenz-backend/src/routes/apis/user-activity-logs-api-router.ts @@ -0,0 +1,11 @@ +import express from 'express'; + +import UserActivityLogsController from '../../controllers/user-activity-logs-controller'; +import safeControllerFunction from "../../shared/safe-controller-function"; + +const userActivityLogsApiRouter = express.Router(); + +userActivityLogsApiRouter.get('/user-recent-tasks', safeControllerFunction(UserActivityLogsController.getRecentTasks)); +userActivityLogsApiRouter.get('/user-time-logged-tasks', safeControllerFunction(UserActivityLogsController.getTimeLoggedTasks)); + +export default userActivityLogsApiRouter; \ No newline at end of file diff --git a/worklenz-backend/src/routes/auth/index.ts b/worklenz-backend/src/routes/auth/index.ts index 1d34fb274..818e5f27b 100644 --- a/worklenz-backend/src/routes/auth/index.ts +++ b/worklenz-backend/src/routes/auth/index.ts @@ -8,6 +8,7 @@ import resetEmailValidator from "../../middlewares/validators/reset-email-valida import updatePasswordValidator from "../../middlewares/validators/update-password-validator"; import passwordValidator from "../../middlewares/validators/password-validator"; import safeControllerFunction from "../../shared/safe-controller-function"; +import FileConstants from "../../shared/file-constants"; const authRouter = express.Router(); @@ -55,6 +56,9 @@ authRouter.get("/google/verify", (req, res) => { })(req, res); }); +// Mobile Google Sign-In using Passport strategy +authRouter.post("/google/mobile", AuthController.googleMobileAuthPassport); + // Passport logout authRouter.get("/logout", AuthController.logout); diff --git a/worklenz-backend/src/shared/constants.ts b/worklenz-backend/src/shared/constants.ts index ffda9e671..61166a2e7 100644 --- a/worklenz-backend/src/shared/constants.ts +++ b/worklenz-backend/src/shared/constants.ts @@ -6,7 +6,7 @@ export const DEFAULT_ERROR_MESSAGE = "Unknown error has occurred."; export const SessionsStatus = { IDLE: "IDLE", STARTED: "STARTED", - ENDED: "ENDED" + ENDED: "ENDED", }; export const LOG_DESCRIPTIONS = { @@ -18,6 +18,33 @@ export const LOG_DESCRIPTIONS = { PROJECT_MEMBER_REMOVED: "was removed from the project by", }; +export const WorklenzColorShades = { + "#154c9b": ["#0D2A50", "#112E54", "#153258", "#19365C", "#1D3A60", "#213E64", "#254268", "#29466C", "#2D4A70", "#314E74"], + "#3b7ad4": ["#224884", "#26528A", "#2A5C90", "#2E6696", "#32709C", "#367AA2", "#3A84A8", "#3E8EAE", "#4298B4", "#46A2BA"], + "#70a6f3": ["#3D5D8A", "#46679E", "#5071B2", "#597BC6", "#6385DA", "#6C8FEE", "#7699F2", "#7FA3F6", "#89ADFA", "#92B7FE"], + "#7781ca": ["#42486F", "#4C5283", "#565C97", "#6066AB", "#6A70BF", "#747AD3", "#7E84E7", "#888EFB", "#9298FF", "#9CA2FF"], + "#9877ca": ["#542D70", "#6E3A8A", "#8847A4", "#A254BE", "#BC61D8", "#D66EF2", "#E07BFC", "#EA88FF", "#F495FF", "#FEA2FF"], + "#c178c9": ["#6A2E6F", "#843B89", "#9E48A3", "#B855BD", "#D262D7", "#EC6FF1", "#F67CFB", "#FF89FF", "#FF96FF", "#FFA3FF"], + "#ee87c5": ["#832C6A", "#9D3984", "#B7469E", "#D153B8", "#EB60D2", "#FF6DEC", "#FF7AF6", "#FF87FF", "#FF94FF", "#FFA1FF"], + "#ca7881": ["#6F2C3E", "#893958", "#A34672", "#BD538C", "#D760A6", "#F16DC0", "#FB7ADA", "#FF87F4", "#FF94FF", "#FFA1FF"], + "#75c9c0": ["#3F6B66", "#497E7A", "#53918E", "#5DA4A2", "#67B7B6", "#71CBCA", "#7BDEDE", "#85F2F2", "#8FFFFF", "#99FFFF"], + "#75c997": ["#3F6B54", "#497E6A", "#53917F", "#5DA495", "#67B7AA", "#71CBBF", "#7BDED4", "#85F2E9", "#8FFFFF", "#99FFFF"], + "#80ca79": ["#456F3E", "#5A804D", "#6F935C", "#84A66B", "#99B97A", "#AECC89", "#C3DF98", "#D8F2A7", "#EDFFB6", "#FFFFC5"], + "#aacb78": ["#5F6F3E", "#7A804D", "#94935C", "#AFA66B", "#CAB97A", "#E5CC89", "#FFDF98", "#FFF2A7", "#FFFFB6", "#FFFFC5"], + "#cbbc78": ["#6F5D3E", "#8A704D", "#A4835C", "#BF966B", "#DAA97A", "#F5BC89", "#FFCF98", "#FFE2A7", "#FFF5B6", "#FFFFC5"], + "#cb9878": ["#704D3E", "#8B604D", "#A6735C", "#C1866B", "#DC997A", "#F7AC89", "#FFBF98", "#FFD2A7", "#FFE5B6", "#FFF8C5"], + "#bb774c": ["#653D27", "#80502C", "#9B6331", "#B67636", "#D1893B", "#EC9C40", "#FFAF45", "#FFC24A", "#FFD54F", "#FFE854"], + "#905b39": ["#4D2F1A", "#623C23", "#774A2C", "#8C5735", "#A1643E", "#B67147", "#CB7E50", "#E08B59", "#F59862", "#FFA56B"], + "#903737": ["#4D1A1A", "#622323", "#772C2C", "#8C3535", "#A13E3E", "#B64747", "#CB5050", "#E05959", "#F56262", "#FF6B6B"], + "#bf4949": ["#661212", "#801B1B", "#992424", "#B32D2D", "#CC3636", "#E63F3F", "#FF4848", "#FF5151", "#FF5A5A", "#FF6363"], + "#f37070": ["#853A3A", "#A04D4D", "#BA6060", "#D47373", "#EF8686", "#FF9999", "#FFA3A3", "#FFACAC", "#FFB6B6", "#FFBFBF"], + "#ff9c3c": ["#8F5614", "#AA6F1F", "#C48829", "#DFA233", "#F9BB3D", "#FFC04E", "#FFC75F", "#FFCE70", "#FFD581", "#FFDB92"], + "#fbc84c": ["#8F6D14", "#AA862F", "#C4A029", "#DFB933", "#F9D23D", "#FFD74E", "#FFDC5F", "#FFE170", "#FFE681", "#FFEB92"], + "#cbc8a1": ["#6F6D58", "#8A886F", "#A4A286", "#BFBC9D", "#DAD6B4", "#F5F0CB", "#FFFEDE", "#FFFFF2", "#FFFFCD", "#FFFFCD"], + "#a9a9a9": ["#5D5D5D", "#757575", "#8D8D8D", "#A5A5A5", "#BDBDBD", "#D5D5D5", "#EDEDED", "#F5F5F5", "#FFFFFF", "#FFFFFF"], + "#767676": ["#404040", "#4D4D4D", "#5A5A5A", "#676767", "#747474", "#818181", "#8E8E8E", "#9B9B9B", "#A8A8A8", "#B5B5B5"] +} as const; + export const WorklenzColorCodes = [ "#154c9b", "#3b7ad4", @@ -46,33 +73,33 @@ export const WorklenzColorCodes = [ ]; export const AvatarNamesMap: { [x: string]: string } = { - "A": "#154c9b", - "B": "#3b7ad4", - "C": "#70a6f3", - "D": "#7781ca", - "E": "#9877ca", - "F": "#c178c9", - "G": "#ee87c5", - "H": "#ca7881", - "I": "#75c9c0", - "J": "#75c997", - "K": "#80ca79", - "L": "#aacb78", - "M": "#cbbc78", - "N": "#cb9878", - "O": "#bb774c", - "P": "#905b39", - "Q": "#903737", - "R": "#bf4949", - "S": "#f37070", - "T": "#ff9c3c", - "U": "#fbc84c", - "V": "#cbc8a1", - "W": "#a9a9a9", - "X": "#767676", - "Y": "#cb9878", - "Z": "#903737", - "+": "#9e9e9e" + A: "#154c9b", + B: "#3b7ad4", + C: "#70a6f3", + D: "#7781ca", + E: "#9877ca", + F: "#c178c9", + G: "#ee87c5", + H: "#ca7881", + I: "#75c9c0", + J: "#75c997", + K: "#80ca79", + L: "#aacb78", + M: "#cbbc78", + N: "#cb9878", + O: "#bb774c", + P: "#905b39", + Q: "#903737", + R: "#bf4949", + S: "#f37070", + T: "#ff9c3c", + U: "#fbc84c", + V: "#cbc8a1", + W: "#a9a9a9", + X: "#767676", + Y: "#cb9878", + Z: "#903737", + "+": "#9e9e9e", }; export const NumbersColorMap: { [x: string]: string } = { @@ -85,19 +112,19 @@ export const NumbersColorMap: { [x: string]: string } = { "6": "#ee87c5", "7": "#ca7881", "8": "#75c9c0", - "9": "#75c997" + "9": "#75c997", }; -export const PriorityColorCodes: { [x: number]: string; } = { +export const PriorityColorCodes: { [x: number]: string } = { 0: "#2E8B57", 1: "#DAA520", - 2: "#CD5C5C" + 2: "#CD5C5C", }; -export const PriorityColorCodesDark: { [x: number]: string; } = { +export const PriorityColorCodesDark: { [x: number]: string } = { 0: "#3CB371", 1: "#B8860B", - 2: "#F08080" + 2: "#F08080", }; export const TASK_STATUS_TODO_COLOR = "#a9a9a9"; @@ -113,7 +140,6 @@ export const TASK_DUE_UPCOMING_COLOR = "#70a6f3"; export const TASK_DUE_OVERDUE_COLOR = "#f37070"; export const TASK_DUE_NO_DUE_COLOR = "#a9a9a9"; - export const DEFAULT_PAGE_SIZE = 20; // S3 Credentials @@ -125,7 +151,8 @@ export const S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY || ""; // Azure Blob Storage Credentials export const STORAGE_PROVIDER = process.env.STORAGE_PROVIDER || "s3"; -export const AZURE_STORAGE_ACCOUNT_NAME = process.env.AZURE_STORAGE_ACCOUNT_NAME; +export const AZURE_STORAGE_ACCOUNT_NAME = + process.env.AZURE_STORAGE_ACCOUNT_NAME; export const AZURE_STORAGE_CONTAINER = process.env.AZURE_STORAGE_CONTAINER; export const AZURE_STORAGE_ACCOUNT_KEY = process.env.AZURE_STORAGE_ACCOUNT_KEY; export const AZURE_STORAGE_URL = process.env.AZURE_STORAGE_URL; @@ -136,7 +163,7 @@ export function getStorageUrl() { console.warn("AZURE_STORAGE_URL is not defined, falling back to S3_URL"); return S3_URL; } - + // Return just the base Azure Blob Storage URL // AZURE_STORAGE_URL should be in the format: https://storageaccountname.blob.core.windows.net return `${AZURE_STORAGE_URL}/${AZURE_STORAGE_CONTAINER}`; @@ -150,16 +177,23 @@ export const TEAM_MEMBER_TREE_MAP_COLOR_ALPHA = "40"; // LICENSING SERVER URLS export const LOCAL_URL = "http://localhost:3001"; -export const UAT_SERVER_URL = process.env.UAT_SERVER_URL || "https://your-uat-server-url"; -export const DEV_SERVER_URL = process.env.DEV_SERVER_URL || "https://your-dev-server-url"; -export const PRODUCTION_SERVER_URL = process.env.PRODUCTION_SERVER_URL || "https://your-production-server-url"; +export const UAT_SERVER_URL = + process.env.UAT_SERVER_URL || "https://your-uat-server-url"; +export const DEV_SERVER_URL = + process.env.DEV_SERVER_URL || "https://your-dev-server-url"; +export const PRODUCTION_SERVER_URL = + process.env.PRODUCTION_SERVER_URL || "https://your-production-server-url"; // *Sync with the client -export const PASSWORD_POLICY = "Minimum of 8 characters, with upper and lowercase and a number and a symbol."; +export const PASSWORD_POLICY = + "Minimum of 8 characters, with upper and lowercase and a number and a symbol."; // paddle status to exclude export const statusExclude = ["past_due", "paused", "deleted"]; +// Trial user team member limit +export const TRIAL_MEMBER_LIMIT = 10; + export const HTML_TAG_REGEXP = /<\/?[^>]+>/gi; export const UNMAPPED = "Unmapped"; @@ -169,5 +203,5 @@ export const DATE_RANGES = { LAST_WEEK: "LAST_WEEK", LAST_MONTH: "LAST_MONTH", LAST_QUARTER: "LAST_QUARTER", - ALL_TIME: "ALL_TIME" + ALL_TIME: "ALL_TIME", }; diff --git a/worklenz-backend/src/socket.io/commands/on-quick-assign-or-remove.ts b/worklenz-backend/src/socket.io/commands/on-quick-assign-or-remove.ts index 4551bd721..6c6415306 100644 --- a/worklenz-backend/src/socket.io/commands/on-quick-assign-or-remove.ts +++ b/worklenz-backend/src/socket.io/commands/on-quick-assign-or-remove.ts @@ -75,7 +75,7 @@ export async function on_quick_assign_or_remove(_io: Server, socket: Socket, dat assign_type: type }); - if (userId !== assignment.user_id) { + if (assignment && userId !== assignment.user_id) { NotificationsService.createTaskUpdate( type, userId as string, @@ -109,6 +109,11 @@ export async function assignMemberIfNot(taskId: string, userId: string, teamId: const result = await db.query(q, [taskId, userId, teamId]); const [data] = result.rows; + if (!data) { + log_error(new Error(`No team member found for userId: ${userId}, teamId: ${teamId}`)); + return; + } + const body = { team_member_id: data.team_member_id, project_id: data.project_id, diff --git a/worklenz-frontend/.env.development b/worklenz-frontend/.env.development index 21605cdfc..fce709527 100644 --- a/worklenz-frontend/.env.development +++ b/worklenz-frontend/.env.development @@ -15,4 +15,8 @@ VITE_RECAPTCHA_SITE_KEY=recaptcha-site-key VITE_WORKLENZ_SESSION_ID=worklenz-session-id # Google Login -VITE_ENABLE_GOOGLE_LOGIN=false \ No newline at end of file +VITE_ENABLE_GOOGLE_LOGIN=false + +# Survey Modal Configuration +# Set to true to enable the survey modal, false to disable it +VITE_ENABLE_SURVEY_MODAL=false \ No newline at end of file diff --git a/worklenz-frontend/.env.example b/worklenz-frontend/.env.example new file mode 100644 index 000000000..fce709527 --- /dev/null +++ b/worklenz-frontend/.env.example @@ -0,0 +1,22 @@ +VITE_API_URL=http://localhost:3000 +VITE_SOCKET_URL=ws://localhost:3000 + +VITE_APP_TITLE=Worklenz +VITE_APP_ENV=development + +# Mixpanel +VITE_MIXPANEL_TOKEN=mixpanel-token + +# Recaptcha +VITE_ENABLE_RECAPTCHA=false +VITE_RECAPTCHA_SITE_KEY=recaptcha-site-key + +# Session ID +VITE_WORKLENZ_SESSION_ID=worklenz-session-id + +# Google Login +VITE_ENABLE_GOOGLE_LOGIN=false + +# Survey Modal Configuration +# Set to true to enable the survey modal, false to disable it +VITE_ENABLE_SURVEY_MODAL=false \ No newline at end of file diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json index 721124f09..bf437005e 100644 --- a/worklenz-frontend/package-lock.json +++ b/worklenz-frontend/package-lock.json @@ -73,7 +73,10 @@ "@types/react-dom": "19.0.0", "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^3.2.4", + "@vitest/ui": "^3.2.4", "autoprefixer": "^10.4.21", + "jsdom": "^26.1.0", "postcss": "^8.5.2", "prettier-plugin-tailwindcss": "^0.6.13", "rollup": "^4.40.2", @@ -729,8 +732,6 @@ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", @@ -744,9 +745,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "ISC", - "optional": true, - "peer": true + "license": "ISC" }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -1027,6 +1026,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@chenshuai2144/sketch-color": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@chenshuai2144/sketch-color/-/sketch-color-1.0.9.tgz", @@ -1056,8 +1065,6 @@ } ], "license": "MIT-0", - "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -1078,8 +1085,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=18" }, @@ -1104,8 +1109,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "@csstools/color-helpers": "^5.0.2", "@csstools/css-calc": "^2.1.4" @@ -1134,8 +1137,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=18" }, @@ -1159,8 +1160,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -1836,6 +1835,16 @@ "node": ">=12" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -1952,6 +1961,13 @@ "node": ">=14" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rc-component/async-validator": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.0.4.tgz", @@ -2697,6 +2713,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", @@ -2713,6 +2739,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", @@ -2865,15 +2898,50 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.4.tgz", - "integrity": "sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.4", - "@vitest/utils": "3.1.4", + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -2882,13 +2950,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.4.tgz", - "integrity": "sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.4", + "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -2897,7 +2965,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": { @@ -2909,9 +2977,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.4.tgz", - "integrity": "sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", "dependencies": { @@ -2922,27 +2990,28 @@ } }, "node_modules/@vitest/runner": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.4.tgz", - "integrity": "sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.1.4", - "pathe": "^2.0.3" + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.4.tgz", - "integrity": "sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.4", + "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -2951,27 +3020,49 @@ } }, "node_modules/@vitest/spy": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.4.tgz", - "integrity": "sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz", + "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", "dependencies": { - "tinyspy": "^3.0.2" + "@vitest/utils": "3.2.4", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.1", + "tinyglobby": "^0.2.14", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "3.2.4" } }, "node_modules/@vitest/utils": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.4.tgz", - "integrity": "sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.4", - "loupe": "^3.1.3", + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, "funding": { @@ -3012,8 +3103,6 @@ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">= 14" } @@ -3172,6 +3261,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.3.tgz", + "integrity": "sha512-MuXMrSLVVoA6sYN/6Hke18vMzrT4TZNbZIj/hvh0fnYFpO+/kFXcLIaiPwXXWaQUPg4yJD8fj+lfJ7/1EBconw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/async-validator": { "version": "1.11.5", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-1.11.5.tgz", @@ -3466,9 +3574,9 @@ } }, "node_modules/chai": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", - "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.1.tgz", + "integrity": "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==", "dev": true, "license": "MIT", "dependencies": { @@ -3479,7 +3587,7 @@ "pathval": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/chalk": { @@ -3755,8 +3863,6 @@ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" @@ -3777,8 +3883,6 @@ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" @@ -3793,8 +3897,6 @@ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -3808,8 +3910,6 @@ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, "license": "BSD-2-Clause", - "optional": true, - "peer": true, "engines": { "node": ">=12" } @@ -3820,8 +3920,6 @@ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" @@ -3868,9 +3966,7 @@ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/deep-eql": { "version": "5.0.2", @@ -4045,8 +4141,6 @@ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", - "optional": true, - "peer": true, "engines": { "node": ">=0.12" }, @@ -4259,6 +4353,13 @@ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", "license": "MIT" }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", @@ -4530,8 +4631,6 @@ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "whatwg-encoding": "^3.1.1" }, @@ -4539,6 +4638,13 @@ "node": ">=18" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "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", @@ -4567,8 +4673,6 @@ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -4583,8 +4687,6 @@ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -4655,8 +4757,6 @@ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -4777,9 +4877,7 @@ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", @@ -4787,6 +4885,60 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -4868,8 +5020,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -4910,8 +5060,6 @@ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -4925,8 +5073,6 @@ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, "license": "BSD-2-Clause", - "optional": true, - "peer": true, "engines": { "node": ">=12" } @@ -4937,8 +5083,6 @@ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" @@ -4953,8 +5097,6 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -5335,9 +5477,9 @@ } }, "node_modules/loupe": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", - "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.0.tgz", + "integrity": "sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw==", "dev": true, "license": "MIT" }, @@ -5372,6 +5514,47 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/matchmediaquery": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/matchmediaquery/-/matchmediaquery-0.4.2.tgz", @@ -5507,6 +5690,16 @@ "node": "*" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5593,9 +5786,7 @@ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/object-assign": { "version": "4.1.1", @@ -5657,8 +5848,6 @@ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "entities": "^6.0.0" }, @@ -5729,9 +5918,9 @@ "license": "MIT" }, "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", "engines": { @@ -6124,8 +6313,6 @@ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=6" } @@ -7271,9 +7458,7 @@ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/rrweb-snapshot": { "version": "2.0.0-alpha.18", @@ -7321,9 +7506,7 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/saxes": { "version": "6.0.0", @@ -7331,8 +7514,6 @@ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, "license": "ISC", - "optional": true, - "peer": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -7420,6 +7601,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sirv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", + "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/socket.io-client": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", @@ -7654,6 +7850,26 @@ "node": ">=8" } }, + "node_modules/strip-literal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", + "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stylis": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", @@ -7735,9 +7951,7 @@ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/tailwindcss": { "version": "3.4.17", @@ -7811,6 +8025,21 @@ "dev": true, "license": "MIT" }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -7922,9 +8151,9 @@ "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.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", "dev": true, "license": "MIT", "engines": { @@ -7942,9 +8171,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": { @@ -7957,8 +8186,6 @@ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "tldts-core": "^6.1.86" }, @@ -7971,9 +8198,7 @@ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -7993,14 +8218,22 @@ "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", "license": "MIT" }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", - "optional": true, - "peer": true, "dependencies": { "tldts": "^6.1.32" }, @@ -8208,17 +8441,17 @@ } }, "node_modules/vite-node": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.4.tgz", - "integrity": "sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.4.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" @@ -8279,32 +8512,34 @@ } }, "node_modules/vitest": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.4.tgz", - "integrity": "sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.1.4", - "@vitest/mocker": "3.1.4", - "@vitest/pretty-format": "^3.1.4", - "@vitest/runner": "3.1.4", - "@vitest/snapshot": "3.1.4", - "@vitest/spy": "3.1.4", - "@vitest/utils": "3.1.4", + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", "chai": "^5.2.0", - "debug": "^4.4.0", + "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", + "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.13", - "tinypool": "^1.0.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.1.4", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8320,8 +8555,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.1.4", - "@vitest/ui": "3.1.4", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, @@ -8349,6 +8584,19 @@ } } }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "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", @@ -8364,8 +8612,6 @@ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -8400,8 +8646,6 @@ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "iconv-lite": "0.6.3" }, @@ -8415,8 +8659,6 @@ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -8573,8 +8815,6 @@ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", - "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -8584,9 +8824,7 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", diff --git a/worklenz-frontend/package.json b/worklenz-frontend/package.json index 7e25181c1..bc90b52a0 100644 --- a/worklenz-frontend/package.json +++ b/worklenz-frontend/package.json @@ -9,7 +9,11 @@ "build": "vite build", "dev-build": "vite build", "serve": "vite preview", - "format": "prettier --write ." + "format": "prettier --write .", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", + "test:ui": "vitest --ui" }, "dependencies": { "@ant-design/colors": "^7.1.0", @@ -77,7 +81,10 @@ "@types/react-dom": "19.0.0", "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^3.2.4", + "@vitest/ui": "^3.2.4", "autoprefixer": "^10.4.21", + "jsdom": "^26.1.0", "postcss": "^8.5.2", "prettier-plugin-tailwindcss": "^0.6.13", "rollup": "^4.40.2", diff --git a/worklenz-frontend/public/js/hubspot.js b/worklenz-frontend/public/js/hubspot.js index d1a3afee3..9fab6c94b 100644 --- a/worklenz-frontend/public/js/hubspot.js +++ b/worklenz-frontend/public/js/hubspot.js @@ -52,6 +52,7 @@ class HubSpotManager { existingStyle.remove(); } + // Apply dark mode CSS if dark theme is active if (isDark) { this.injectDarkModeCSS(); } @@ -76,40 +77,27 @@ class HubSpotManager { style.id = this.styleId; style.textContent = ` /* HubSpot Chat Widget Dark Mode Override */ + /* + Note: We can only style the container backgrounds, not the widget UI inside the iframe. + HubSpot does not currently support external dark mode theming for the chat UI itself. + */ #hubspot-conversations-inline-parent, - #hubspot-conversations-iframe-container, - .shadow-2xl.widget-align-right.widget-align-bottom, - [data-test-id="chat-widget"], - [class*="VizExCollapsedChat"], - [class*="VizExExpandedChat"], - iframe[src*="hubspot"] { - filter: invert(1) hue-rotate(180deg) !important; - background: transparent !important; + #hubspot-conversations-iframe-container { + background: #141414 !important; } - /* Target HubSpot widget container backgrounds */ #hubspot-conversations-inline-parent div, #hubspot-conversations-iframe-container div, [data-test-id="chat-widget"] div { background-color: transparent !important; } - - /* Prevent double inversion of images, avatars, and icons */ - #hubspot-conversations-iframe-container img, - #hubspot-conversations-iframe-container [style*="background-image"], - #hubspot-conversations-iframe-container svg, - iframe[src*="hubspot"] img, - iframe[src*="hubspot"] svg, - [data-test-id="chat-widget"] img, - [data-test-id="chat-widget"] svg { - filter: invert(1) hue-rotate(180deg) !important; - } - - /* Additional targeting for widget launcher and chat bubble */ - div[class*="shadow-2xl"], - div[class*="widget-align"], - div[style*="position: fixed"] { - filter: invert(1) hue-rotate(180deg) !important; + /* Ensure Worklenz app elements are not affected by HubSpot styles */ + .ant-menu, + .ant-menu *, + [class*="settings"], + [class*="sidebar"], + .worklenz-app *:not([id*="hubspot"]):not([class*="widget"]) { + filter: none !important; } `; document.head.appendChild(style); @@ -134,4 +122,11 @@ document.addEventListener('DOMContentLoaded', () => { // Make available globally for potential cleanup window.HubSpotManager = hubspot; -}); \ No newline at end of file +}); + +// Add this style to ensure the chat widget uses the light color scheme +(function() { + var style = document.createElement('style'); + style.innerHTML = '#hubspot-messages-iframe-container { color-scheme: light !important; }'; + document.head.appendChild(style); +})(); \ No newline at end of file diff --git a/worklenz-frontend/public/locales/alb/account-setup.json b/worklenz-frontend/public/locales/alb/account-setup.json index d5f624b3d..3d2e785b6 100644 --- a/worklenz-frontend/public/locales/alb/account-setup.json +++ b/worklenz-frontend/public/locales/alb/account-setup.json @@ -1,31 +1,194 @@ { "continue": "Vazhdo", - "setupYourAccount": "Konfiguro Llogarinë Tënde në Worklenz.", - "organizationStepTitle": "Emërtoni Organizatën Tuaj", - "organizationStepLabel": "Zgjidhni një emër për llogarinë tuaj në Worklenz.", - - "projectStepTitle": "Krijoni projektin tuaj të parë", - "projectStepLabel": "Në cilin projekt po punoni aktualisht?", + "setupYourAccount": "Konfiguro llogarinë tënde.", + "organizationStepTitle": "Emërto organizatën tënde", + "organizationStepLabel": "Zgjidh një emër për llogarinë tënde në Worklenz.", + "organizationStepWelcome": "Konfiguro llogarinë tënde në Worklenz.", + "organizationStepDescription": "Le të fillojmë duke konfiguruar organizatën tënde. Kjo do të jetë hapësira kryesore e punës për ekipin tënd.", + "organizationStepTooltip": "Ky emër do të shfaqet në hapësirën tënde të punës dhe mund të ndryshohet më vonë në cilësime.", + "organizationStepNeedIdeas": "Keni nevojë për ide?", + "organizationStepUseDetected": "Përdorimi i zbuluar:", + "organizationStepCharacters": "karaktere", + "organizationStepGoodLength": "Gjatësi e mirë", + "organizationStepTooShort": "Shumë i shkurtër", + "organizationStepNamingTips": "Këshilla për emërtimin", + "organizationStepTip1": "Mbaje të thjeshtë dhe të lehtë për t'u mbajtur mend", + "organizationStepTip2": "Përfaqëso industrinë ose vlerat e tua", + "organizationStepTip3": "Mendo për rritjen në të ardhmen", + "organizationStepTip4": "Bëje unik dhe të përshtatshëm për markë", + "organizationStepSuggestionsTitle": "Sugjerime për emra", + "organizationStepCategory1": "Kompani Teknologjie", + "organizationStepCategory2": "Agjenci Kreative", + "organizationStepCategory3": "Konsulencë", + "organizationStepCategory4": "Startupe", + "organizationStepSuggestionsNote": "Këto janë vetëm shembuj për të të ndihmuar të fillosh. Zgjidh diçka që përfaqëson organizatën tënde.", + "organizationStepPrivacyNote": "Emri i organizatës tënde është privat dhe i dukshëm vetëm për anëtarët e ekipit.", + "projectStepTitle": "Krijo projektin tënd të parë", + "projectStepLabel": "Në cilin projekt po punon tani?", "projectStepPlaceholder": "p.sh. Plani i Marketingut", - - "tasksStepTitle": "Krijoni detyrat tuaja të para", - "tasksStepLabel": "Shkruani disa detyra që do të kryeni në", + "tasksStepTitle": "Krijo detyrat e tua të para", + "tasksStepLabel": "Shkruaj disa detyra që do të kryesh në", "tasksStepAddAnother": "Shto një tjetër", - - "emailPlaceholder": "Adresa email", - "invalidEmail": "Ju lutemi vendosni një adresë email të vlefshme", + "emailPlaceholder": "Adresa e emailit", + "invalidEmail": "Ju lutem vendosni një adresë emaili të vlefshme", "or": "ose", "templateButton": "Importo nga shablloni", - "goBack": "Kthehu Mbrapa", + "goBack": "Kthehu mbrapa", "cancel": "Anulo", "create": "Krijo", "templateDrawerTitle": "Zgjidh nga shabllonet", "step3InputLabel": "Fto me email", "addAnother": "Shto një tjetër", - "skipForNow": "Kalo tani për tani", - "formTitle": "Krijoni detyrën tuaj të parë.", - "step3Title": "Fto ekipin tënd të punojë me", + "skipForNow": "Kalo për tani", + "skipping": "Duke kaluar...", + "formTitle": "Krijo detyrën tënde të parë.", + "step3Title": "Fto ekipin tënd për të punuar së bashku", "maxMembers": " (Mund të ftoni deri në 5 anëtarë)", - "maxTasks": " (Mund të krijoni deri në 5 detyra)" + "maxTasks": " (Mund të krijoni deri në 5 detyra)", + "membersStepTitle": "Fto ekipin tënd", + "membersStepDescription": "Shto anëtarë ekipi në \"{{organizationName}}\" dhe filloni bashkëpunimin", + "memberPlaceholder": "Anëtari i ekipit {{index}} - Shkruani adresën e emailit", + "validEmailAddress": "Adresë emaili e vlefshme", + "addAnotherTeamMember": "Shto një anëtar tjetër të ekipit ({{current}}/{{max}})", + "canInviteLater": "Gjithmonë mund të ftoni anëtarë të ekipit më vonë", + "skipStepDescription": "Nuk i keni adresat e emailit gati? Asnjë problem! Mund ta kaloni këtë hap dhe të ftoni anëtarë nga paneli i projektit më vonë.", + "orgCategoryTech": "Kompani Teknologjie", + "orgCategoryCreative": "Agjenci Kreative", + "orgCategoryConsulting": "Konsulencë", + "orgCategoryStartups": "Startupe", + "namingTip1": "Mbaje të thjeshtë dhe të lehtë për t'u mbajtur mend", + "namingTip2": "Përfaqëso industrinë ose vlerat e tua", + "namingTip3": "Mendo për rritjen në të ardhmen", + "namingTip4": "Bëje unik dhe të përshtatshëm për markë", + "aboutYouTitle": "Na trego për veten tënde", + "aboutYouDescription": "Na ndihmo të personalizojmë përvojën tënde", + "orgTypeQuestion": "Cila përshkruan më mirë organizatën tënde?", + "userRoleQuestion": "Cili është roli yt?", + "yourNeedsTitle": "Cilat janë nevojat e tua kryesore?", + "yourNeedsDescription": "Zgjidh të gjitha që aplikohen për të na ndihmuar të konfigurojmë hapësirën tënde të punës", + "yourNeedsQuestion": "Si do ta përdorësh kryesisht Worklenz?", + "useCaseTaskOrg": "Organizo dhe ndiq detyrat", + "useCaseTeamCollab": "Puno së bashku pa pengesa", + "useCaseResourceMgmt": "Menaxho kohën dhe burimet", + "useCaseClientComm": "Qëndro i lidhur me klientët", + "useCaseTimeTrack": "Monitoro orët e projektit", + "useCaseOther": "Diçka tjetër", + "selectedText": "zgjedhur", + "previousToolsQuestion": "Çfarë mjetesh ke përdorur më parë? (Opsionale)", + "discoveryTitle": "Edhe një gjë e fundit...", + "discoveryDescription": "Na ndihmo të kuptojmë si e zbulove Worklenz", + "discoveryQuestion": "Si dëgjove për ne?", + "allSetTitle": "Çdo gjë gati!", + "allSetDescription": "Le të krijojmë projektin tënd të parë dhe të fillojmë me Worklenz", + "surveyCompleteTitle": "Faleminderit!", + "surveyCompleteDescription": "Përgjigjet tuaja na ndihmojnë të përmirësojmë Worklenz për të gjithë", + "aboutYouStepName": "Rreth teje", + "yourNeedsStepName": "Nevojat e tua", + "discoveryStepName": "Zbulimi", + "stepProgress": "Hapi {step} nga 3: {title}", + "projectStepHeader": "Le të krijojmë projektin tënd të parë", + "projectStepSubheader": "Fillo nga e para ose përdor një shabllon për të filluar më shpejt", + "startFromScratch": "Fillo nga e para", + "templateSelected": "Shablloni i zgjedhur më poshtë", + "quickSuggestions": "Sugjerime të shpejta:", + "orText": "OSE", + "startWithTemplate": "Fillo me një shabllon", + "clearToSelectTemplate": "Pastro emrin e projektit më sipër për të zgjedhur një shabllon", + "templateHeadStart": "Fillo më shpejt me struktura të gatshme projekti", + "browseAllTemplates": "Shfleto të gjitha shabllonet", + "templatesAvailable": "15+ shabllone të specializuara sipas industrisë në dispozicion", + "chooseTemplate": "Zgjidh një shabllon që i përshtatet llojit të projektit tënd", + "createProject": "Krijo projekt", + "templateSoftwareDev": "Zhvillim Softueri", + "templateSoftwareDesc": "Sprint-e agile, ndjekje gabimesh, lëshime", + "templateMarketing": "Fushatë Marketingu", + "templateMarketingDesc": "Planifikim fushate, kalendar përmbajtjesh", + "templateConstruction": "Projekt Ndërtimi", + "templateConstructionDesc": "Faza, leje, kontraktorë", + "templateStartup": "Lansim Startup-i", + "templateStartupDesc": "Zhvillim MVP, financim, rritje", + "tasksStepDescription": "Ndaji \"{{projectName}}\" në detyra të veprueshme për të filluar", + "taskPlaceholder": "Detyra {{index}} - p.sh., Çfarë duhet bërë?", + "addAnotherTask": "Shto një detyrë tjetër ({{current}}/{{max}})", + "surveyStepTitle": "Na trego për veten tënde", + "surveyStepLabel": "Na ndihmo të personalizojmë përvojën tënde në Worklenz duke iu përgjigjur disa pyetjeve.", + "organizationType": "Cila përshkruan më mirë organizatën tënde?", + "organizationTypeFreelancer": "Freelancer", + "organizationTypeStartup": "Startup", + "organizationTypeSmallMediumBusiness": "Biznes i Vogël ose i Mesëm", + "organizationTypeAgency": "Agjenci", + "organizationTypeEnterprise": "Ndërmarrje", + "organizationTypeOther": "Tjetër", + "userRole": "Cili është roli yt?", + "userRoleFounderCeo": "Themelues / CEO", + "userRoleProjectManager": "Menaxher Projekti", + "userRoleSoftwareDeveloper": "Zhvillues Softueri", + "userRoleDesigner": "Dizajner", + "userRoleOperations": "Operacionet", + "userRoleOther": "Tjetër", + "mainUseCases": "Për çfarë do ta përdorësh kryesisht Worklenz?", + "mainUseCasesTaskManagement": "Menaxhim detyrash", + "mainUseCasesTeamCollaboration": "Bashkëpunim ekipi", + "mainUseCasesResourcePlanning": "Planifikim burimesh", + "mainUseCasesClientCommunication": "Komunikim & raportim me klientët", + "mainUseCasesTimeTracking": "Ndjekje kohe", + "mainUseCasesOther": "Tjetër", + "previousTools": "Çfarë mjetesh ke përdorur para Worklenz?", + "previousToolsPlaceholder": "p.sh. Trello, Asana, Monday.com", + "howHeardAbout": "Si dëgjove për Worklenz?", + "howHeardAboutGoogleSearch": "Kërkim në Google", + "howHeardAboutTwitter": "Twitter", + "howHeardAboutLinkedin": "LinkedIn", + "howHeardAboutFriendColleague": "Një mik ose koleg", + "howHeardAboutBlogArticle": "Një blog ose artikull", + "howHeardAboutOther": "Tjetër", + + "aboutYouStepTitle": "Na trego për veten", + "aboutYouStepDescription": "Na ndihmo të personalizojmë përvojën tënde", + "yourNeedsStepTitle": "Cilat janë nevojat e tua kryesore?", + "yourNeedsStepDescription": "Zgjidh të gjitha që aplikohen për të na ndihmuar të konfigurojmë hapësirën tënde të punës", + "selected": "zgjedhur", + "previousToolsLabel": "Çfarë mjetesh ke përdorur më parë? (Opsionale)", + + "roleSuggestions": { + "designer": "UI/UX, Grafikë, Kreativ", + "developer": "Frontend, Backend, Full-stack", + "projectManager": "Planifikim, Koordinim", + "marketing": "Përmbajtje, Media Sociale, Rritje", + "sales": "Zhvillim Biznesi, Marrëdhënie me Klientë", + "operations": "Administratë, HR, Financa" + }, + + "languages": { + "en": "Anglisht", + "es": "Spanjisht", + "pt": "Portugalisht", + "de": "Gjermanisht", + "alb": "Shqip", + "zh": "Kinezçe" + }, + + "orgSuggestions": { + "tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"], + "creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"], + "consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"], + "startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"] + }, + + "projectSuggestions": { + "freelancer": ["Projekti i Klientit", "Përditësim Portfolio", "Markë Personale"], + "startup": ["Zhvillim MVP", "Lansim Produkti", "Kërkim Tregu"], + "agency": ["Fushatë Klienti", "Strategji Markë", "Ridizajnim Website"], + "enterprise": ["Migrim Sistemi", "Optimizim Procesesh", "Trajnim Ekipi"] + }, + + "useCaseDescriptions": { + "taskManagement": "Organizoj dhe ndjek detyrat", + "teamCollaboration": "Punojmë së bashku pa probleme", + "resourcePlanning": "Menaxhoj kohën dhe burimet", + "clientCommunication": "Qëndroj i lidhur me klientët", + "timeTracking": "Monitoroj orët e projektit", + "other": "Diçka tjetër" + } } diff --git a/worklenz-frontend/public/locales/alb/admin-center/current-bill.json b/worklenz-frontend/public/locales/alb/admin-center/current-bill.json index 1f76f32b1..f5e74aaf4 100644 --- a/worklenz-frontend/public/locales/alb/admin-center/current-bill.json +++ b/worklenz-frontend/public/locales/alb/admin-center/current-bill.json @@ -106,6 +106,7 @@ "expirestoday": "sot", "expirestomorrow": "nesër", + "expiredDayAgo": "{{days}} ditë më parë", "expiredDaysAgo": "{{days}} ditë më parë", "continueWith": "Vazhdo me {{plan}}", diff --git a/worklenz-frontend/public/locales/alb/common.json b/worklenz-frontend/public/locales/alb/common.json index 5af25f694..f6d934771 100644 --- a/worklenz-frontend/public/locales/alb/common.json +++ b/worklenz-frontend/public/locales/alb/common.json @@ -5,5 +5,40 @@ "signup-failed": "Regjistrimi dështoi. Ju lutemi sigurohuni që të gjitha fushat e nevojshme janë plotësuar dhe provoni përsëri.", "reconnecting": "Jeni shkëputur nga serveri.", "connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.", - "connection-restored": "U lidhët me serverin me sukses" + "connection-restored": "U lidhët me serverin me sukses", + "cancel": "Anulo", + "update-available": "Worklenz u përditesua!", + "update-description": "Një version i ri i Worklenz është i disponueshëm me karakteristikat dhe përmirësimet më të fundit.", + "update-instruction": "Për eksperiencën më të mirë, ju lutemi rifreskoni faqen për të aplikuar ndryshimet e reja.", + "update-whats-new": "💡 <1>Çfarë ka të re: Përmirësim i performancës, rregullime të gabimeve dhe eksperiencön e përmirësuar e përdoruesit", + "update-now": "Përditeso tani", + "update-later": "Më vonë", + "updating": "Duke u përditesuar...", + "license-expired-title": "Periudhja e provës ka skaduar", + "license-expired-subtitle": "Periudhja juaj e provës në Worklenz ka përfunduar. Ju lutemi përditesohuni për të vazhduar të shijoni të gjitha veçoritë.", + "license-expired-features": "Përditesohuni tani për të shkyçur:", + "license-expired-feature-1": "✓ Projekte dhe detyra të pakufizuara", + "license-expired-feature-2": "✓ Raportim dhe analitika të avancuara", + "license-expired-feature-3": "✓ Veçori bashkëpunimi në grup", + "license-expired-feature-4": "✓ Mbështetje me prioritet", + "license-expired-upgrade": "Përditeso tani", + "license-expired-days-remaining": "{{days}} ditë të mbetura në periudhën tuaj të provës", + "trial-expiring-soon": "Periudhja juaj e provës skadon në {{days}} ditë", + "trial-expiring-soon_plural": "Periudhja juaj e provës skadon në {{days}} ditë", + "trial-expiring-today": "Periudhja juaj e provës skadon sot!", + "trial-expiring-upgrade": "Përditesohuni tani për të mbajtur të gjitha të dhënat tuaja dhe vazhdoni pa ndërprerje", + "trial-badge-days": "{{days}} ditë të mbetura", + "trial-badge-today": "Dita e fundit!", + "trial-badge-hours": "{{hours}} orë të mbetura", + "trial-alert-admin-note": "Ju mund të aksesoni ende Qendrën e Administrimit për të menaxhuar abonimin tuaj", + "trial-alert-dismiss": "Hidhe për sot", + "license-expiring-soon": "Licenca juaj skadon në {{days}} ditë", + "license-expiring-soon_plural": "Licenca juaj skadon në {{days}} ditë", + "license-expiring-today": "Licenca juaj skadon sot!", + "license-expired-grace-period": "Licenca juaj ka skaduar. {{days}} ditë periudhë tolerance e mbetur", + "license-expired-grace-period_plural": "Licenca juaj ka skaduar. {{days}} ditë periudhë tolerance të mbetura", + "license-expiring-upgrade": "Rinovoni tani për të mbajtur të gjitha të dhënat tuaja dhe vazhdoni pa ndërprerje", + "license-badge-days": "{{days}} ditë të mbetura", + "license-badge-today": "Dita e fundit!", + "license-badge-hours": "{{hours}} orë të mbetura" } diff --git a/worklenz-frontend/public/locales/alb/home.json b/worklenz-frontend/public/locales/alb/home.json index 58d26e0b1..818210624 100644 --- a/worklenz-frontend/public/locales/alb/home.json +++ b/worklenz-frontend/public/locales/alb/home.json @@ -41,6 +41,22 @@ "list": "Listë", "calendar": "Kalendar", "tasks": "Detyrat", - "refresh": "Rifresko" + "refresh": "Rifresko", + "recentActivity": "Aktiviteti i Fundit", + "recentTasks": "Detyrat e Fundit", + "recentTasksSegment": "Detyrat e Fundit", + "timeLogged": "Koha e Regjistruar", + "timeLoggedSegment": "Koha e Regjistruar", + "noRecentTasks": "Asnjë detyrë e fundit", + "noTimeLoggedTasks": "Asnjë detyrë me kohë të regjistruar", + "activityTag": "Aktiviteti", + "timeLogTag": "Regjistrim Kohe", + "timerTag": "Kohëmatës", + "activitySingular": "aktivitet", + "activityPlural": "aktivitete", + "recentTaskAriaLabel": "Detyrë e fundit:", + "timeLoggedTaskAriaLabel": "Detyrë me kohë të regjistruar:", + "errorLoadingRecentTasks": "Gabim në ngarkimin e detyrave të fundit", + "errorLoadingTimeLoggedTasks": "Gabim në ngarkimin e detyrave me kohë të regjistruar" } } diff --git a/worklenz-frontend/public/locales/alb/kanban-board.json b/worklenz-frontend/public/locales/alb/kanban-board.json index def705aa1..50835235a 100644 --- a/worklenz-frontend/public/locales/alb/kanban-board.json +++ b/worklenz-frontend/public/locales/alb/kanban-board.json @@ -10,6 +10,17 @@ "deleteConfirmationOk": "Po", "deleteConfirmationCancel": "Anulo", + "deleteTaskTitle": "Fshi Detyrën", + "deleteTaskContent": "Jeni i sigurt që doni të fshini këtë detyrë? Kjo veprim nuk mund të zhbëhet.", + "deleteTaskConfirm": "Fshi", + "deleteTaskCancel": "Anulo", + + "deleteStatusTitle": "Fshi Statusin", + "deleteStatusContent": "Jeni i sigurt që doni të fshini këtë status? Kjo veprim nuk mund të zhbëhet.", + + "deletePhaseTitle": "Fshi Fazen", + "deletePhaseContent": "Jeni i sigurt që doni të fshini këtë fazë? Kjo veprim nuk mund të zhbëhet.", + "dueDate": "Data e përfundimit", "cancel": "Anulo", @@ -26,5 +37,17 @@ "noDueDate": "Pa datë përfundimi", "save": "Ruaj", "clear": "Pastro", - "nextWeek": "Javën e ardhshme" + "nextWeek": "Javën e ardhshme", + "noSubtasks": "Pa nëndetyra", + "showSubtasks": "Shfaq nëndetyrat", + "hideSubtasks": "Fshih nëndetyrat", + + "errorLoadingTasks": "Gabim gjatë ngarkimit të detyrave", + "noTasksFound": "Nuk u gjetën detyra", + "loadingFilters": "Duke ngarkuar filtra...", + "failedToUpdateColumnOrder": "Dështoi përditësimi i rendit të kolonave", + "failedToUpdatePhaseOrder": "Dështoi përditësimi i rendit të fazave", + "pleaseTryAgain": "Ju lutemi provoni përsëri", + "taskNotCompleted": "Detyra nuk është përfunduar", + "completeTaskDependencies": "Ju lutemi përfundoni varësitë e detyrës para se të vazhdoni" } diff --git a/worklenz-frontend/public/locales/alb/navbar.json b/worklenz-frontend/public/locales/alb/navbar.json index 88c53de46..5821dc26a 100644 --- a/worklenz-frontend/public/locales/alb/navbar.json +++ b/worklenz-frontend/public/locales/alb/navbar.json @@ -18,6 +18,7 @@ "profileTooltip": "Shiko profilin", "adminCenter": "Qendra Administrative", "settings": "Cilësimet", + "deleteAccount": "Fshij Llogarinë", "logOut": "Dil", "notificationsDrawer": { "read": "Lexuara e njoftimet ", diff --git a/worklenz-frontend/public/locales/alb/project-view-members.json b/worklenz-frontend/public/locales/alb/project-view-members.json index 239b77e95..b64063446 100644 --- a/worklenz-frontend/public/locales/alb/project-view-members.json +++ b/worklenz-frontend/public/locales/alb/project-view-members.json @@ -13,5 +13,6 @@ "deleteButtonTooltip": "Hiq nga projekti", "memberCount": "Anëtar", "membersCountPlural": "Anëtarë", - "emptyText": "Nuk ka bashkëngjitje në projekt." + "emptyText": "Nuk ka bashkëngjitje në projekt.", + "searchPlaceholder": "Kërko anëtarë" } diff --git a/worklenz-frontend/public/locales/alb/project-view/project-member-drawer.json b/worklenz-frontend/public/locales/alb/project-view/project-member-drawer.json index aa6637e17..03c891c00 100644 --- a/worklenz-frontend/public/locales/alb/project-view/project-member-drawer.json +++ b/worklenz-frontend/public/locales/alb/project-view/project-member-drawer.json @@ -3,5 +3,9 @@ "searchLabel": "Shtoni anëtarë duke shkruar emrin ose email-in e tyre", "searchPlaceholder": "Shkruani emrin ose email-in", "inviteAsAMember": "Fto si anëtar", - "inviteNewMemberByEmail": "Fto anëtar të ri me email" + "inviteNewMemberByEmail": "Fto anëtar të ri me email", + "members": "Anëtarë", + "copyProjectLink": "Kopjo lidhjen e projektit", + "inviteMember": "Fto anëtar", + "alsoInviteToProject": "Fto edhe në projekt" } diff --git a/worklenz-frontend/public/locales/alb/settings/labels.json b/worklenz-frontend/public/locales/alb/settings/labels.json index 40e6361b0..fe8cb40a1 100644 --- a/worklenz-frontend/public/locales/alb/settings/labels.json +++ b/worklenz-frontend/public/locales/alb/settings/labels.json @@ -7,5 +7,9 @@ "searchPlaceholder": "Kërko sipas emrit", "emptyText": "Etiketat mund të krijohen gjatë përditësimit ose krijimit të detyrave.", "pinTooltip": "Klikoni për ta fiksuar në menynë kryesore", - "colorChangeTooltip": "Klikoni për të ndryshuar ngjyrën" + "colorChangeTooltip": "Klikoni për të ndryshuar ngjyrën", + "pageTitle": "Menaxho Etiketat", + "deleteConfirmTitle": "Jeni i sigurt që dëshironi ta fshini këtë?", + "deleteButton": "Fshi", + "cancelButton": "Anulo" } diff --git a/worklenz-frontend/public/locales/alb/settings/team-members.json b/worklenz-frontend/public/locales/alb/settings/team-members.json index 955954dc9..935d5a0f3 100644 --- a/worklenz-frontend/public/locales/alb/settings/team-members.json +++ b/worklenz-frontend/public/locales/alb/settings/team-members.json @@ -28,7 +28,7 @@ "jobTitleLabel": "Titulli i Punës", "jobTitlePlaceholder": "Zgjidh ose kërko titull pune (Opsionale)", "memberAccessLabel": "Niveli i Qasjes", - "addToTeamButton": "Shto Anëtar në Ekip", + "addToTeamButton": "Dërgo ftesën", "updateButton": "Ruaj Ndryshimet", "resendInvitationButton": "Dërgo Përsëri Email-in e Ftesës", "invitationSentSuccessMessage": "Ftesa për ekip u dërgua me sukses!", @@ -43,5 +43,6 @@ "updatedText": "Përditësuar", "noResultFound": "Shkruani një adresë email dhe shtypni Enter...", "jobTitlesFetchError": "Dështoi marrja e titujve të punës", - "invitationResent": "Ftesa u dërgua sërish me sukses!" + "invitationResent": "Ftesa u dërgua sërish me sukses!", + "copyTeamLink": "Kopjo lidhjen e ekipit" } diff --git a/worklenz-frontend/public/locales/alb/survey.json b/worklenz-frontend/public/locales/alb/survey.json new file mode 100644 index 000000000..65713542e --- /dev/null +++ b/worklenz-frontend/public/locales/alb/survey.json @@ -0,0 +1,14 @@ +{ + "modalTitle": "Ndihmoni të përmirësojmë përvojën tuaj", + "skip": "Kalo për tani", + "previous": "Prapa", + "next": "Tjetra", + "completeSurvey": "Përfundo Anketën", + "submitting": "Duke dërguar përgjigjet tuaja...", + "submitSuccessTitle": "Faleminderit!", + "submitSuccessSubtitle": "Feedback-u juaj na ndihmon të përmirësojmë Worklenz për të gjithë.", + "submitSuccessMessage": "Faleminderit që plotësuat anketën!", + "submitErrorMessage": "Dështoi dërgimi i anketës. Ju lutemi provoni përsëri.", + "submitErrorLog": "Dështoi dërgimi i anketës", + "fetchErrorLog": "Dështoi marrja e anketës" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/alb/task-list-filters.json b/worklenz-frontend/public/locales/alb/task-list-filters.json index 4fc4dbdf7..27806a768 100644 --- a/worklenz-frontend/public/locales/alb/task-list-filters.json +++ b/worklenz-frontend/public/locales/alb/task-list-filters.json @@ -84,5 +84,12 @@ "close": "Mbyll", "cannotMoveStatus": "Nuk mund të lëvizet statusi", "cannotMoveStatusMessage": "Nuk mund të lëvizet ky status sepse do të linte kategorinë '{{categoryName}}' bosh. Çdo kategori duhet të ketë të paktën një status.", - "ok": "OK" + "ok": "OK", + "clearSort": "Pastro Renditjen", + "sortAscending": "Rendit në Rritje", + "sortDescending": "Rendit në Zbritje", + "sortByField": "Rendit sipas {{field}}", + "ascendingOrder": "Rritës", + "descendingOrder": "Zbritës", + "currentSort": "Renditja aktuale: {{field}} {{order}}" } diff --git a/worklenz-frontend/public/locales/alb/task-list-table.json b/worklenz-frontend/public/locales/alb/task-list-table.json index c009e734d..5d7756eb3 100644 --- a/worklenz-frontend/public/locales/alb/task-list-table.json +++ b/worklenz-frontend/public/locales/alb/task-list-table.json @@ -57,6 +57,9 @@ "contextMenu": { "assignToMe": "Cakto mua", + "copyLink": "Kopjo lidhjen e detyrës", + "linkCopied": "Lidhja u kopjua në clipboard", + "linkCopyFailed": "Dështoi kopjimi i lidhjes", "moveTo": "Zhvendos në", "unarchive": "Ç'arkivizo", "archive": "Arkivizo", @@ -133,5 +136,11 @@ "dependencies": "Detyra ka varësi", "recurring": "Detyrë përsëritëse" } + }, + + "timer": { + "conflictTitle": "Kronómetr Tashë Në Ecuri", + "conflictMessage": "Ju keni një kronómetr në ecuri për \"{{taskName}}\" në projektin \"{{projectName}}\". Dëshironi ta ndaloni atë kronómetr dhe të filloni një të ri për këtë detyrë?", + "stopAndStart": "Ndalo & Fillo Kronómetr të Ri" } } diff --git a/worklenz-frontend/public/locales/de/account-setup.json b/worklenz-frontend/public/locales/de/account-setup.json index ddfb7b80a..d496d75c2 100644 --- a/worklenz-frontend/public/locales/de/account-setup.json +++ b/worklenz-frontend/public/locales/de/account-setup.json @@ -3,7 +3,28 @@ "setupYourAccount": "Richten Sie Ihr Worklenz-Konto ein.", "organizationStepTitle": "Organisation benennen", - "organizationStepLabel": "Wählen Sie einen Namen für Ihr Worklenz-Konto.", + "organizationStepWelcome": "Willkommen bei Worklenz!", + "organizationStepDescription": "Beginnen wir mit der Einrichtung Ihrer Organisation. Dies wird der Hauptarbeitsplatz für Ihr Team.", + "organizationStepLabel": "Organisationsname", + "organizationStepPlaceholder": "z.B. Acme Corporation", + "organizationStepTooltip": "Dieser Name wird in Ihrem Arbeitsbereich angezeigt und kann später in den Einstellungen geändert werden.", + "organizationStepNeedIdeas": "Brauchen Sie Ideen?", + "organizationStepUseDetected": "Erkannt verwenden:", + "organizationStepCharacters": "Zeichen", + "organizationStepGoodLength": "Gute Länge", + "organizationStepTooShort": "Zu kurz", + "organizationStepNamingTips": "Namensgebungstipps", + "organizationStepTip1": "Halten Sie es einfach und einprägsam", + "organizationStepTip2": "Spiegeln Sie Ihre Branche oder Werte wider", + "organizationStepTip3": "Denken Sie an zukünftiges Wachstum", + "organizationStepTip4": "Machen Sie es einzigartig und markenfähig", + "organizationStepSuggestionsTitle": "Namensvorschläge", + "organizationStepCategory1": "Tech-Unternehmen", + "organizationStepCategory2": "Kreativagenturen", + "organizationStepCategory3": "Beratung", + "organizationStepCategory4": "Startups", + "organizationStepSuggestionsNote": "Dies sind nur Beispiele für den Einstieg. Wählen Sie etwas, das Ihre Organisation repräsentiert.", + "organizationStepPrivacyNote": "Ihr Organisationsname ist privat und nur für Ihre Teammitglieder sichtbar.", "projectStepTitle": "Erstellen Sie Ihr erstes Projekt", "projectStepLabel": "An welchem Projekt arbeiten Sie gerade?", @@ -24,8 +45,170 @@ "step3InputLabel": "Per E-Mail einladen", "addAnother": "Weitere hinzufügen", "skipForNow": "Jetzt überspringen", + "skipping": "Überspringen...", "formTitle": "Erstellen Sie Ihre erste Aufgabe.", "step3Title": "Laden Sie Ihr Team zur Zusammenarbeit ein", "maxMembers": " (Sie können bis zu 5 Mitglieder einladen)", - "maxTasks": " (Sie können bis zu 5 Aufgaben erstellen)" + "maxTasks": " (Sie können bis zu 5 Aufgaben erstellen)", + + "membersStepTitle": "Laden Sie Ihr Team ein", + "membersStepDescription": "Teammitglieder zu \"{{organizationName}}\" hinzufügen und mit der Zusammenarbeit beginnen", + "memberPlaceholder": "Teammitglied {{index}} - E-Mail-Adresse eingeben", + "validEmailAddress": "Gültige E-Mail-Adresse", + "addAnotherTeamMember": "Weiteres Teammitglied hinzufügen ({{current}}/{{max}})", + "canInviteLater": "Sie können Teammitglieder jederzeit später einladen", + "skipStepDescription": "Haben Sie keine E-Mail-Adressen bereit? Kein Problem! Sie können diesen Schritt überspringen und Teammitglieder später über Ihr Projekt-Dashboard einladen.", + + "orgCategoryTech": "Technologieunternehmen", + "orgCategoryCreative": "Kreativagenturen", + "orgCategoryConsulting": "Beratung", + "orgCategoryStartups": "Startups", + "namingTip1": "Halten Sie es einfach und einprägsam", + "namingTip2": "Spiegeln Sie Ihre Branche oder Werte wider", + "namingTip3": "Denken Sie an zukünftiges Wachstum", + "namingTip4": "Machen Sie es einzigartig und markenfähig", + + "aboutYouTitle": "Erzählen Sie uns von sich", + "aboutYouDescription": "Helfen Sie uns, Ihre Erfahrung zu personalisieren", + "orgTypeQuestion": "Was beschreibt Ihre Organisation am besten?", + "userRoleQuestion": "Was ist Ihre Rolle?", + + "yourNeedsTitle": "Was sind Ihre Hauptbedürfnisse?", + "yourNeedsDescription": "Wählen Sie alle zutreffenden aus, um uns bei der Einrichtung Ihres Arbeitsbereichs zu helfen", + "yourNeedsQuestion": "Wie werden Sie Worklenz hauptsächlich nutzen?", + "useCaseTaskOrg": "Aufgaben organisieren und verfolgen", + "useCaseTeamCollab": "Nahtlos zusammenarbeiten", + "useCaseResourceMgmt": "Zeit und Ressourcen verwalten", + "useCaseClientComm": "Mit Kunden in Verbindung bleiben", + "useCaseTimeTrack": "Projektstunden überwachen", + "useCaseOther": "Etwas anderes", + "selectedText": "ausgewählt", + "previousToolsQuestion": "Welche Tools haben Sie zuvor verwendet? (Optional)", + "previousToolsPlaceholder": "z.B. Asana, Trello, Jira, Monday.com, etc.", + + "discoveryTitle": "Eine letzte Sache...", + "discoveryDescription": "Helfen Sie uns zu verstehen, wie Sie Worklenz entdeckt haben", + "discoveryQuestion": "Wie haben Sie von uns erfahren?", + "allSetTitle": "Sie sind bereit!", + "allSetDescription": "Lassen Sie uns Ihr erstes Projekt erstellen und mit Worklenz beginnen", + "surveyCompleteTitle": "Vielen Dank!", + "surveyCompleteDescription": "Ihr Feedback hilft uns, Worklenz für alle zu verbessern", + "aboutYouStepName": "Über Sie", + "yourNeedsStepName": "Ihre Bedürfnisse", + "discoveryStepName": "Entdeckung", + "stepProgress": "Schritt {step} von 3: {title}", + + "projectStepHeader": "Lassen Sie uns Ihr erstes Projekt erstellen", + "projectStepSubheader": "Von Grund auf beginnen oder eine Vorlage verwenden, um schneller voranzukommen", + "startFromScratch": "Von Grund auf beginnen", + "templateSelected": "Vorlage unten ausgewählt", + "quickSuggestions": "Schnelle Vorschläge:", + "orText": "ODER", + "startWithTemplate": "Mit einer Vorlage beginnen", + "clearToSelectTemplate": "Projektname oben löschen, um eine Vorlage auszuwählen", + "templateHeadStart": "Verschaffen Sie sich einen Vorsprung mit vorgefertigten Projektstrukturen", + "browseAllTemplates": "Alle Vorlagen durchsuchen", + "templatesAvailable": "15+ branchenspezifische Vorlagen verfügbar", + "chooseTemplate": "Wählen Sie eine Vorlage, die zu Ihrem Projekttyp passt", + "createProject": "Projekt erstellen", + + "templateSoftwareDev": "Softwareentwicklung", + "templateSoftwareDesc": "Agile Sprints, Fehlerverfolgung, Releases", + "templateMarketing": "Marketing-Kampagne", + "templateMarketingDesc": "Kampagnenplanung, Content-Kalender", + "templateConstruction": "Bauprojekt", + "templateConstructionDesc": "Phasen, Genehmigungen, Auftragnehmer", + "templateStartup": "Startup-Launch", + "templateStartupDesc": "MVP-Entwicklung, Finanzierung, Wachstum", + + "tasksStepTitle": "Fügen Sie Ihre ersten Aufgaben hinzu", + "tasksStepDescription": "Unterteilen Sie \"{{projectName}}\" in umsetzbare Aufgaben, um zu beginnen", + "taskPlaceholder": "Aufgabe {{index}} - z.B., Was muss getan werden?", + "addAnotherTask": "Weitere Aufgabe hinzufügen ({{current}}/{{max}})", + + "surveyStepTitle": "Erzählen Sie uns von sich", + "surveyStepLabel": "Helfen Sie uns, Ihre Worklenz-Erfahrung zu personalisieren, indem Sie ein paar Fragen beantworten.", + + "organizationType": "Was beschreibt Ihre Organisation am besten?", + "organizationTypeFreelancer": "Freelancer", + "organizationTypeStartup": "Startup", + "organizationTypeSmallMediumBusiness": "Kleines oder mittleres Unternehmen", + "organizationTypeAgency": "Agentur", + "organizationTypeEnterprise": "Unternehmen", + "organizationTypeOther": "Andere", + + "userRole": "Was ist Ihre Rolle?", + "userRoleFounderCeo": "Gründer / CEO", + "userRoleProjectManager": "Projektmanager", + "userRoleSoftwareDeveloper": "Software-Entwickler", + "userRoleDesigner": "Designer", + "userRoleOperations": "Betrieb", + "userRoleOther": "Andere", + + "mainUseCases": "Wofür werden Sie Worklenz hauptsächlich verwenden?", + "mainUseCasesTaskManagement": "Aufgabenverwaltung", + "mainUseCasesTeamCollaboration": "Teamzusammenarbeit", + "mainUseCasesResourcePlanning": "Ressourcenplanung", + "mainUseCasesClientCommunication": "Kundenkommunikation & Berichterstattung", + "mainUseCasesTimeTracking": "Zeiterfassung", + "mainUseCasesOther": "Andere", + + "previousTools": "Welche Tools haben Sie vor Worklenz verwendet?", + "previousToolsPlaceholder": "z.B. Trello, Asana, Monday.com", + + "howHeardAbout": "Wie haben Sie von Worklenz erfahren?", + "howHeardAboutGoogleSearch": "Google-Suche", + "howHeardAboutTwitter": "Twitter", + "howHeardAboutLinkedin": "LinkedIn", + "howHeardAboutFriendColleague": "Ein Freund oder Kollege", + "howHeardAboutBlogArticle": "Ein Blog oder Artikel", + "howHeardAboutOther": "Andere", + + "aboutYouStepTitle": "Erzählen Sie uns von sich", + "aboutYouStepDescription": "Helfen Sie uns, Ihre Erfahrung zu personalisieren", + "yourNeedsStepTitle": "Was sind Ihre Hauptbedürfnisse?", + "yourNeedsStepDescription": "Wählen Sie alle zutreffenden aus, um uns bei der Einrichtung Ihres Arbeitsbereichs zu helfen", + "selected": "ausgewählt", + "previousToolsLabel": "Welche Tools haben Sie zuvor verwendet? (Optional)", + + "roleSuggestions": { + "designer": "UI/UX, Grafiken, Kreativ", + "developer": "Frontend, Backend, Full-stack", + "projectManager": "Planung, Koordination", + "marketing": "Inhalt, Social Media, Wachstum", + "sales": "Geschäftsentwicklung, Kundenbeziehungen", + "operations": "Admin, HR, Finanzen" + }, + + "languages": { + "en": "English", + "es": "Español", + "pt": "Português", + "de": "Deutsch", + "alb": "Shqip", + "zh": "简体中文" + }, + + "orgSuggestions": { + "tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"], + "creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"], + "consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"], + "startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"] + }, + + "projectSuggestions": { + "freelancer": ["Kundenprojekt", "Portfolio-Update", "Persönliche Marke"], + "startup": ["MVP-Entwicklung", "Produktlaunch", "Marktforschung"], + "agency": ["Kundenkampagne", "Markenstrategie", "Website-Redesign"], + "enterprise": ["Systemumstellung", "Prozessoptimierung", "Teamschulung"] + }, + + "useCaseDescriptions": { + "taskManagement": "Aufgaben organisieren und verfolgen", + "teamCollaboration": "Nahtlos zusammenarbeiten", + "resourcePlanning": "Zeit und Ressourcen verwalten", + "clientCommunication": "Mit Kunden in Verbindung bleiben", + "timeTracking": "Projektstunden überwachen", + "other": "Etwas anderes" + } } diff --git a/worklenz-frontend/public/locales/de/admin-center/configuration.json b/worklenz-frontend/public/locales/de/admin-center/configuration.json new file mode 100644 index 000000000..79d33f278 --- /dev/null +++ b/worklenz-frontend/public/locales/de/admin-center/configuration.json @@ -0,0 +1,26 @@ +{ + "billingDetails": "Abrechnungsdetails", + "name": "Name", + "namePlaceholder": "Name", + "emailAddress": "E-Mail-Adresse", + "emailPlaceholder": "E-Mail-Adresse", + "contactNumber": "Telefonnummer", + "phoneNumberPlaceholder": "Telefonnummer", + "phoneValidationError": "Telefonnummer muss genau 10 Ziffern haben", + "companyDetails": "Firmendetails", + "companyName": "Firmenname", + "companyNamePlaceholder": "Firmenname", + "addressLine01": "Adresszeile 01", + "addressLine01Placeholder": "Adresszeile 01", + "addressLine02": "Adresszeile 02", + "addressLine02Placeholder": "Adresszeile 02", + "country": "Land", + "countryPlaceholder": "Land", + "city": "Stadt", + "cityPlaceholder": "Stadt", + "state": "Bundesland", + "statePlaceholder": "Bundesland", + "postalCode": "Postleitzahl", + "postalCodePlaceholder": "Postleitzahl", + "save": "Speichern" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/de/admin-center/current-bill.json b/worklenz-frontend/public/locales/de/admin-center/current-bill.json index fcf2c636e..b08056ea7 100644 --- a/worklenz-frontend/public/locales/de/admin-center/current-bill.json +++ b/worklenz-frontend/public/locales/de/admin-center/current-bill.json @@ -106,6 +106,7 @@ "expirestoday": "heute", "expirestomorrow": "morgen", + "expiredDayAgo": "vor {{days}} Tag", "expiredDaysAgo": "vor {{days}} Tagen", "continueWith": "Fortfahren mit {{plan}}", diff --git a/worklenz-frontend/public/locales/de/auth/signup.json b/worklenz-frontend/public/locales/de/auth/signup.json index 8eb7e5a3e..885b81474 100644 --- a/worklenz-frontend/public/locales/de/auth/signup.json +++ b/worklenz-frontend/public/locales/de/auth/signup.json @@ -7,12 +7,12 @@ "emailLabel": "E-Mail", "emailPlaceholder": "Ihre E-Mail-Adresse eingeben", "emailRequired": "Bitte geben Sie Ihre E-Mail-Adresse ein!", - "passwordLabel": "Password", - "passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.", - "passwordPlaceholder": "Enter your password", + "passwordLabel": "Passwort", + "passwordGuideline": "Das Passwort muss mindestens 8 Zeichen lang sein und Groß- und Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten.", + "passwordPlaceholder": "Geben Sie Ihr Passwort ein", "passwordRequired": "Bitte geben Sie Ihr Passwort ein!", "passwordMinCharacterRequired": "Das Passwort muss mindestens 8 Zeichen lang sein!", - "passwordMaxCharacterRequired": "Password must be at most 32 characters!", + "passwordMaxCharacterRequired": "Das Passwort darf maximal 32 Zeichen lang sein!", "passwordPatternRequired": "Das Passwort entspricht nicht den Anforderungen!", "strongPasswordPlaceholder": "Ein stärkeres Passwort eingeben", "passwordValidationAltText": "Das Passwort muss mindestens 8 Zeichen enthalten, mit Groß- und Kleinbuchstaben, einer Zahl und einem Sonderzeichen.", diff --git a/worklenz-frontend/public/locales/de/common.json b/worklenz-frontend/public/locales/de/common.json index 937ad4a9e..aaa8c93fa 100644 --- a/worklenz-frontend/public/locales/de/common.json +++ b/worklenz-frontend/public/locales/de/common.json @@ -5,5 +5,50 @@ "signup-failed": "Registrierung fehlgeschlagen. Bitte füllen Sie alle erforderlichen Felder aus und versuchen Sie es erneut.", "reconnecting": "Vom Server getrennt.", "connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.", - "connection-restored": "Erfolgreich mit dem Server verbunden" + "connection-restored": "Erfolgreich mit dem Server verbunden", + "cancel": "Abbrechen", + "update-available": "Worklenz aktualisiert!", + "update-description": "Eine neue Version von Worklenz ist verfügbar mit den neuesten Funktionen und Verbesserungen.", + "update-instruction": "Für die beste Erfahrung laden Sie bitte die Seite neu, um die neuen Änderungen zu übernehmen.", + "update-whats-new": "💡 <1>Was ist neu: Verbesserte Leistung, Fehlerbehebungen und verbesserte Benutzererfahrung", + "update-now": "Jetzt aktualisieren", + "update-later": "Später", + "updating": "Wird aktualisiert...", + "license-expired-title": "Abonnement abgelaufen", + "license-expired-subtitle": "Ihr Worklenz-Abonnement ist beendet. Bitte erneuern Sie es, um alle Funktionen weiterhin zu nutzen.", + "license-expired-trial-title": "Testversion abgelaufen", + "license-expired-trial-subtitle": "Ihre Worklenz-Testversion ist beendet. Bitte upgraden Sie, um alle Funktionen weiter zu nutzen.", + "license-expired-custom-title": "Benutzerdefinierter Plan abgelaufen", + "license-expired-custom-subtitle": "Ihr benutzerdefinierter Plan ist abgelaufen. Bitte kontaktieren Sie den Support oder erneuern Sie ihn.", + "license-expired-features": "Jetzt erneuern und weiterhin genießen:", + "license-expired-trial-features": "Jetzt upgraden und freischalten:", + "license-expired-custom-features": "Support kontaktieren, um fortzufahren mit:", + "license-expired-feature-1": "✓ Unbegrenzte Projekte und Aufgaben", + "license-expired-feature-2": "✓ Erweiterte Berichte und Analysen", + "license-expired-feature-3": "✓ Team-Zusammenarbeitsfunktionen", + "license-expired-feature-4": "✓ Priority-Support", + "license-expired-upgrade": "Jetzt erneuern", + "license-expired-trial-upgrade": "Jetzt upgraden", + "license-expired-custom-upgrade": "Support kontaktieren", + "license-expired-contacting-support": "Support wird kontaktiert...", + "license-expired-message-sent": "Nachricht gesendet ✓", + "license-expired-days-remaining": "{{days}} Tage verbleiben in Ihrer Testversion", + "trial-expiring-soon": "Ihre Testversion läuft in {{days}} Tag ab", + "trial-expiring-soon_plural": "Ihre Testversion läuft in {{days}} Tagen ab", + "trial-expiring-today": "Ihre Testversion läuft heute ab!", + "trial-expiring-upgrade": "Upgraden Sie jetzt, um alle Ihre Daten zu behalten und ohne Unterbrechung fortzufahren", + "trial-badge-days": "{{days}}T übrig", + "trial-badge-today": "Letzter Tag!", + "trial-badge-hours": "{{hours}}h übrig", + "trial-alert-admin-note": "Sie können weiterhin auf das Admin Center zugreifen, um Ihr Abonnement zu verwalten", + "trial-alert-dismiss": "Für heute ausblenden", + "license-expiring-soon": "Ihre Lizenz läuft in {{days}} Tag ab", + "license-expiring-soon_plural": "Ihre Lizenz läuft in {{days}} Tagen ab", + "license-expiring-today": "Ihre Lizenz läuft heute ab!", + "license-expired-grace-period": "Ihre Lizenz ist abgelaufen. {{days}} Tag Gnadenfrist verbleibend", + "license-expired-grace-period_plural": "Ihre Lizenz ist abgelaufen. {{days}} Tage Gnadenfrist verbleibend", + "license-expiring-upgrade": "Erneuern Sie jetzt, um alle Ihre Daten zu behalten und ohne Unterbrechung fortzufahren", + "license-badge-days": "{{days}}T übrig", + "license-badge-today": "Letzter Tag!", + "license-badge-hours": "{{hours}}h übrig" } diff --git a/worklenz-frontend/public/locales/de/home.json b/worklenz-frontend/public/locales/de/home.json index cc8689521..bc9ff9acf 100644 --- a/worklenz-frontend/public/locales/de/home.json +++ b/worklenz-frontend/public/locales/de/home.json @@ -41,6 +41,22 @@ "list": "Liste", "calendar": "Kalender", "tasks": "Aufgaben", - "refresh": "Aktualisieren" + "refresh": "Aktualisieren", + "recentActivity": "Aktuelle Aktivitäten", + "recentTasks": "Aktuelle Aufgaben", + "recentTasksSegment": "Aktuelle Aufgaben", + "timeLogged": "Erfasste Zeit", + "timeLoggedSegment": "Erfasste Zeit", + "noRecentTasks": "Keine aktuellen Aufgaben", + "noTimeLoggedTasks": "Keine Aufgaben mit erfasster Zeit", + "activityTag": "Aktivität", + "timeLogTag": "Zeiterfassung", + "timerTag": "Timer", + "activitySingular": "Aktivität", + "activityPlural": "Aktivitäten", + "recentTaskAriaLabel": "Aktuelle Aufgabe:", + "timeLoggedTaskAriaLabel": "Aufgabe mit erfasster Zeit:", + "errorLoadingRecentTasks": "Fehler beim Laden aktueller Aufgaben", + "errorLoadingTimeLoggedTasks": "Fehler beim Laden der Zeiterfassung" } } diff --git a/worklenz-frontend/public/locales/de/kanban-board.json b/worklenz-frontend/public/locales/de/kanban-board.json index 70e1f6cab..10b58b95b 100644 --- a/worklenz-frontend/public/locales/de/kanban-board.json +++ b/worklenz-frontend/public/locales/de/kanban-board.json @@ -10,6 +10,17 @@ "deleteConfirmationOk": "Ja", "deleteConfirmationCancel": "Abbrechen", + "deleteTaskTitle": "Aufgabe löschen", + "deleteTaskContent": "Sind Sie sicher, dass Sie diese Aufgabe löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "deleteTaskConfirm": "Löschen", + "deleteTaskCancel": "Abbrechen", + + "deleteStatusTitle": "Status löschen", + "deleteStatusContent": "Sind Sie sicher, dass Sie diesen Status löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + + "deletePhaseTitle": "Phase löschen", + "deletePhaseContent": "Sind Sie sicher, dass Sie diese Phase löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "dueDate": "Fälligkeitsdatum", "cancel": "Abbrechen", @@ -26,5 +37,17 @@ "noDueDate": "Kein Fälligkeitsdatum", "save": "Speichern", "clear": "Löschen", - "nextWeek": "Nächste Woche" + "nextWeek": "Nächste Woche", + "noSubtasks": "Keine Unteraufgaben", + "showSubtasks": "Unteraufgaben anzeigen", + "hideSubtasks": "Unteraufgaben ausblenden", + + "errorLoadingTasks": "Fehler beim Laden der Aufgaben", + "noTasksFound": "Keine Aufgaben gefunden", + "loadingFilters": "Filter werden geladen...", + "failedToUpdateColumnOrder": "Fehler beim Aktualisieren der Spaltenreihenfolge", + "failedToUpdatePhaseOrder": "Fehler beim Aktualisieren der Phasenreihenfolge", + "pleaseTryAgain": "Bitte versuchen Sie es erneut", + "taskNotCompleted": "Aufgabe ist nicht abgeschlossen", + "completeTaskDependencies": "Bitte schließen Sie die Aufgabenabhängigkeiten ab, bevor Sie fortfahren" } diff --git a/worklenz-frontend/public/locales/de/navbar.json b/worklenz-frontend/public/locales/de/navbar.json index c84912e42..95ebe690b 100644 --- a/worklenz-frontend/public/locales/de/navbar.json +++ b/worklenz-frontend/public/locales/de/navbar.json @@ -18,6 +18,7 @@ "profileTooltip": "Profil anzeigen", "adminCenter": "Admin-Center", "settings": "Einstellungen", + "deleteAccount": "Account Löschen", "logOut": "Abmelden", "notificationsDrawer": { "read": "Gelesene Benachrichtigungen", diff --git a/worklenz-frontend/public/locales/de/project-view-members.json b/worklenz-frontend/public/locales/de/project-view-members.json index eee5d0a1c..4e40f7576 100644 --- a/worklenz-frontend/public/locales/de/project-view-members.json +++ b/worklenz-frontend/public/locales/de/project-view-members.json @@ -13,5 +13,6 @@ "deleteButtonTooltip": "Aus Projekt entfernen", "memberCount": "Mitglied", "membersCountPlural": "Mitglieder", - "emptyText": "Es gibt keine Anhänge in diesem Projekt." + "emptyText": "Es gibt keine Anhänge in diesem Projekt.", + "searchPlaceholder": "Mitglieder suchen" } diff --git a/worklenz-frontend/public/locales/de/project-view/project-member-drawer.json b/worklenz-frontend/public/locales/de/project-view/project-member-drawer.json index cb391b2cb..b92056e87 100644 --- a/worklenz-frontend/public/locales/de/project-view/project-member-drawer.json +++ b/worklenz-frontend/public/locales/de/project-view/project-member-drawer.json @@ -3,5 +3,9 @@ "searchLabel": "Mitglieder hinzufügen durch Eingabe von Name oder E-Mail", "searchPlaceholder": "Name oder E-Mail eingeben", "inviteAsAMember": "Als Mitglied einladen", - "inviteNewMemberByEmail": "Neues Mitglied per E-Mail einladen" + "inviteNewMemberByEmail": "Neues Mitglied per E-Mail einladen", + "members": "Mitglieder", + "copyProjectLink": "Projektlink kopieren", + "inviteMember": "Mitglied einladen", + "alsoInviteToProject": "Auch zum Projekt einladen" } diff --git a/worklenz-frontend/public/locales/de/settings/labels.json b/worklenz-frontend/public/locales/de/settings/labels.json index 18b6a0218..8514b5cd5 100644 --- a/worklenz-frontend/public/locales/de/settings/labels.json +++ b/worklenz-frontend/public/locales/de/settings/labels.json @@ -7,5 +7,9 @@ "searchPlaceholder": "Nach Name suchen", "emptyText": "Labels können beim Aktualisieren oder Erstellen von Aufgaben erstellt werden.", "pinTooltip": "Zum Anheften an das Hauptmenü klicken", - "colorChangeTooltip": "Zum Ändern der Farbe klicken" + "colorChangeTooltip": "Zum Ändern der Farbe klicken", + "pageTitle": "Labels verwalten", + "deleteConfirmTitle": "Sind Sie sicher, dass Sie dies löschen möchten?", + "deleteButton": "Löschen", + "cancelButton": "Abbrechen" } diff --git a/worklenz-frontend/public/locales/de/settings/team-members.json b/worklenz-frontend/public/locales/de/settings/team-members.json index d223f08ea..55c017131 100644 --- a/worklenz-frontend/public/locales/de/settings/team-members.json +++ b/worklenz-frontend/public/locales/de/settings/team-members.json @@ -28,7 +28,7 @@ "jobTitleLabel": "Jobtitel", "jobTitlePlaceholder": "Jobtitel auswählen oder suchen (optional)", "memberAccessLabel": "Zugriffslevel", - "addToTeamButton": "Mitglied zum Team hinzufügen", + "addToTeamButton": "Einladung senden", "updateButton": "Änderungen speichern", "resendInvitationButton": "Einladungs-E-Mail erneut senden", "invitationSentSuccessMessage": "Team-Einladung erfolgreich versendet!", @@ -43,5 +43,6 @@ "updatedText": "Aktualisiert", "noResultFound": "Geben Sie eine E-Mail-Adresse ein und drücken Sie Enter...", "jobTitlesFetchError": "Fehler beim Abrufen der Jobtitel", - "invitationResent": "Einladung erfolgreich erneut gesendet!" + "invitationResent": "Einladung erfolgreich erneut gesendet!", + "copyTeamLink": "Team-Link kopieren" } diff --git a/worklenz-frontend/public/locales/de/survey.json b/worklenz-frontend/public/locales/de/survey.json new file mode 100644 index 000000000..92b7c8ceb --- /dev/null +++ b/worklenz-frontend/public/locales/de/survey.json @@ -0,0 +1,14 @@ +{ + "modalTitle": "Helfen Sie uns, Ihre Erfahrung zu verbessern", + "skip": "Für jetzt überspringen", + "previous": "Zurück", + "next": "Weiter", + "completeSurvey": "Umfrage abschließen", + "submitting": "Ihre Antworten werden übermittelt...", + "submitSuccessTitle": "Danke!", + "submitSuccessSubtitle": "Ihr Feedback hilft uns, Worklenz für alle zu verbessern.", + "submitSuccessMessage": "Danke, dass Sie die Umfrage abgeschlossen haben!", + "submitErrorMessage": "Umfrage konnte nicht übermittelt werden. Bitte versuchen Sie es erneut.", + "submitErrorLog": "Umfrageübermittlung fehlgeschlagen", + "fetchErrorLog": "Umfrageabruf fehlgeschlagen" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/de/task-list-filters.json b/worklenz-frontend/public/locales/de/task-list-filters.json index 18d50b6c0..19280036e 100644 --- a/worklenz-frontend/public/locales/de/task-list-filters.json +++ b/worklenz-frontend/public/locales/de/task-list-filters.json @@ -84,5 +84,12 @@ "close": "Schließen", "cannotMoveStatus": "Status kann nicht verschoben werden", "cannotMoveStatusMessage": "Dieser Status kann nicht verschoben werden, da die Kategorie '{{categoryName}}' leer bleiben würde. Jede Kategorie muss mindestens einen Status haben.", - "ok": "OK" + "ok": "OK", + "clearSort": "Sortierung löschen", + "sortAscending": "Aufsteigend sortieren", + "sortDescending": "Absteigend sortieren", + "sortByField": "Sortieren nach {{field}}", + "ascendingOrder": "Aufsteigend", + "descendingOrder": "Absteigend", + "currentSort": "Aktuelle Sortierung: {{field}} {{order}}" } diff --git a/worklenz-frontend/public/locales/de/task-list-table.json b/worklenz-frontend/public/locales/de/task-list-table.json index 23439a1b8..4adfd5f7a 100644 --- a/worklenz-frontend/public/locales/de/task-list-table.json +++ b/worklenz-frontend/public/locales/de/task-list-table.json @@ -57,6 +57,9 @@ "contextMenu": { "assignToMe": "Mir zuweisen", + "copyLink": "Link zur Aufgabe kopieren", + "linkCopied": "Link in die Zwischenablage kopiert", + "linkCopyFailed": "Fehler beim Kopieren des Links", "moveTo": "Verschieben nach", "unarchive": "Dearchivieren", "archive": "Archivieren", @@ -133,5 +136,11 @@ "dependencies": "Aufgabe hat Abhängigkeiten", "recurring": "Wiederkehrende Aufgabe" } + }, + + "timer": { + "conflictTitle": "Timer läuft bereits", + "conflictMessage": "Sie haben einen Timer für \"{{taskName}}\" im Projekt \"{{projectName}}\" laufen. Möchten Sie diesen Timer stoppen und einen neuen für diese Aufgabe starten?", + "stopAndStart": "Stoppen & Neuen Timer starten" } } diff --git a/worklenz-frontend/public/locales/en/account-setup.json b/worklenz-frontend/public/locales/en/account-setup.json index 5e71ca40b..1d960237f 100644 --- a/worklenz-frontend/public/locales/en/account-setup.json +++ b/worklenz-frontend/public/locales/en/account-setup.json @@ -1,15 +1,35 @@ { "continue": "Continue", - "setupYourAccount": "Setup Your Worklenz Account.", + "setupYourAccount": "Setup Your Account.", "organizationStepTitle": "Name Your Organization", - "organizationStepLabel": "Pick a name for your Worklenz account.", + "organizationStepWelcome": "Welcome to Worklenz!", + "organizationStepDescription": "Let's start by setting up your organization. This will be the main workspace for your team.", + "organizationStepLabel": "Organization name", + "organizationStepPlaceholder": "e.g. Acme Corporation", + "organizationStepTooltip": "This name will appear in your workspace and can be changed later in settings.", + "organizationStepNeedIdeas": "Need ideas?", + "organizationStepUseDetected": "Use detected:", + "organizationStepCharacters": "characters", + "organizationStepGoodLength": "Good length", + "organizationStepTooShort": "Too short", + "organizationStepNamingTips": "Naming Tips", + "organizationStepTip1": "Keep it simple and memorable", + "organizationStepTip2": "Reflect your industry or values", + "organizationStepTip3": "Think about future growth", + "organizationStepTip4": "Make it unique and brandable", + "organizationStepSuggestionsTitle": "Name Suggestions", + "organizationStepCategory1": "Tech Companies", + "organizationStepCategory2": "Creative Agencies", + "organizationStepCategory3": "Consulting", + "organizationStepCategory4": "Startups", + "organizationStepSuggestionsNote": "These are just examples to get you started. Choose something that represents your organization.", + "organizationStepPrivacyNote": "Your organization name is private and only visible to your team members.", "projectStepTitle": "Create your first project", "projectStepLabel": "What project are you working on right now?", "projectStepPlaceholder": "e.g. Marketing Plan", - "tasksStepTitle": "Create your first tasks", "tasksStepLabel": "Type a few tasks that you are going to do in", "tasksStepAddAnother": "Add another", @@ -24,8 +44,169 @@ "step3InputLabel": "Invite with email", "addAnother": "Add another", "skipForNow": "Skip for now", + "skipping": "Skipping...", "formTitle": "Create your first task.", "step3Title": "Invite your team to work with", "maxMembers": " (You can invite up to 5 members)", - "maxTasks": " (You can create up to 5 tasks)" + "maxTasks": " (You can create up to 5 tasks)", + + "membersStepTitle": "Invite your team", + "membersStepDescription": "Add team members to \"{{organizationName}}\" and start collaborating", + "memberPlaceholder": "Team member {{index}} - Enter email address", + "validEmailAddress": "Valid email address", + "addAnotherTeamMember": "Add another team member ({{current}}/{{max}})", + "canInviteLater": "You can always invite team members later", + "skipStepDescription": "Don't have email addresses ready? No problem! You can skip this step and invite team members from your project dashboard later.", + + "orgCategoryTech": "Tech Companies", + "orgCategoryCreative": "Creative Agencies", + "orgCategoryConsulting": "Consulting", + "orgCategoryStartups": "Startups", + "namingTip1": "Keep it simple and memorable", + "namingTip2": "Reflect your industry or values", + "namingTip3": "Think about future growth", + "namingTip4": "Make it unique and brandable", + + "aboutYouTitle": "Tell us about yourself", + "aboutYouDescription": "Help us personalize your experience", + "orgTypeQuestion": "What best describes your organization?", + "userRoleQuestion": "What's your role?", + + "yourNeedsTitle": "What are your main needs?", + "yourNeedsDescription": "Select all that apply to help us set up your workspace", + "yourNeedsQuestion": "How will you primarily use Worklenz?", + "useCaseTaskOrg": "Organize and track tasks", + "useCaseTeamCollab": "Work together seamlessly", + "useCaseResourceMgmt": "Manage time and resources", + "useCaseClientComm": "Stay connected with clients", + "useCaseTimeTrack": "Monitor project hours", + "useCaseOther": "Something else", + "selectedText": "selected", + "previousToolsQuestion": "What tools have you used before? (Optional)", + + "discoveryTitle": "One last thing...", + "discoveryDescription": "Help us understand how you discovered Worklenz", + "discoveryQuestion": "How did you hear about us?", + "allSetTitle": "You're all set!", + "allSetDescription": "Let's create your first project and get started with Worklenz", + "surveyCompleteTitle": "Thank you!", + "surveyCompleteDescription": "Your feedback helps us improve Worklenz for everyone", + "aboutYouStepName": "About You", + "yourNeedsStepName": "Your Needs", + "discoveryStepName": "Discovery", + "stepProgress": "Step {step} of 3: {title}", + + "projectStepHeader": "Let's create your first project", + "projectStepSubheader": "Start from scratch or use a template to get going faster", + "startFromScratch": "Start from scratch", + "templateSelected": "Template selected below", + "quickSuggestions": "Quick suggestions:", + "orText": "OR", + "startWithTemplate": "Start with a template", + "clearToSelectTemplate": "Clear project name above to select a template", + "templateHeadStart": "Get a head start with pre-built project structures", + "browseAllTemplates": "Browse All Templates", + "templatesAvailable": "15+ industry-specific templates available", + "chooseTemplate": "Choose a template that matches your project type", + "createProject": "Create Project", + + "templateSoftwareDev": "Software Development", + "templateSoftwareDesc": "Agile sprints, bug tracking, releases", + "templateMarketing": "Marketing Campaign", + "templateMarketingDesc": "Campaign planning, content calendar", + "templateConstruction": "Construction Project", + "templateConstructionDesc": "Phases, permits, contractors", + "templateStartup": "Startup Launch", + "templateStartupDesc": "MVP development, funding, growth", + + "tasksStepTitle": "Add your first tasks", + "tasksStepDescription": "Break down \"{{projectName}}\" into actionable tasks to get started", + "taskPlaceholder": "Task {{index}} - e.g., What needs to be done?", + "addAnotherTask": "Add another task ({{current}}/{{max}})", + + "surveyStepTitle": "Tell us about yourself", + "surveyStepLabel": "Help us personalize your Worklenz experience by answering a few questions.", + + "organizationType": "What best describes your organization?", + "organizationTypeFreelancer": "Freelancer", + "organizationTypeStartup": "Startup", + "organizationTypeSmallMediumBusiness": "Small or Medium Business", + "organizationTypeAgency": "Agency", + "organizationTypeEnterprise": "Enterprise", + "organizationTypeOther": "Other", + + "userRole": "What is your role?", + "userRoleFounderCeo": "Founder / CEO", + "userRoleProjectManager": "Project Manager", + "userRoleSoftwareDeveloper": "Software Developer", + "userRoleDesigner": "Designer", + "userRoleOperations": "Operations", + "userRoleOther": "Other", + + "mainUseCases": "What will you mainly use Worklenz for?", + "mainUseCasesTaskManagement": "Task management", + "mainUseCasesTeamCollaboration": "Team collaboration", + "mainUseCasesResourcePlanning": "Resource planning", + "mainUseCasesClientCommunication": "Client communication & reporting", + "mainUseCasesTimeTracking": "Time tracking", + "mainUseCasesOther": "Other", + + "previousTools": "What tool(s) were you using before Worklenz?", + "previousToolsPlaceholder": "e.g. Trello, Asana, Monday.com", + + "howHeardAbout": "How did you hear about Worklenz?", + "howHeardAboutGoogleSearch": "Google Search", + "howHeardAboutTwitter": "Twitter", + "howHeardAboutLinkedin": "LinkedIn", + "howHeardAboutFriendColleague": "A friend or colleague", + "howHeardAboutBlogArticle": "A blog or article", + "howHeardAboutOther": "Other", + + "aboutYouStepTitle": "Tell us about yourself", + "aboutYouStepDescription": "Help us personalize your experience", + "yourNeedsStepTitle": "What are your main needs?", + "yourNeedsStepDescription": "Select all that apply to help us set up your workspace", + "selected": "selected", + "previousToolsLabel": "What tools have you used before? (Optional)", + + "roleSuggestions": { + "designer": "UI/UX, Graphics, Creative", + "developer": "Frontend, Backend, Full-stack", + "projectManager": "Planning, Coordination", + "marketing": "Content, Social Media, Growth", + "sales": "Business Development, Client Relations", + "operations": "Admin, HR, Finance" + }, + + "languages": { + "en": "English", + "es": "Español", + "pt": "Português", + "de": "Deutsch", + "alb": "Shqip", + "zh": "简体中文" + }, + + "orgSuggestions": { + "tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"], + "creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"], + "consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"], + "startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"] + }, + + "projectSuggestions": { + "freelancer": ["Client Project", "Portfolio Update", "Personal Brand"], + "startup": ["MVP Development", "Product Launch", "Market Research"], + "agency": ["Client Campaign", "Brand Strategy", "Website Redesign"], + "enterprise": ["System Migration", "Process Optimization", "Team Training"] + }, + + "useCaseDescriptions": { + "taskManagement": "Organize and track tasks", + "teamCollaboration": "Work together seamlessly", + "resourcePlanning": "Manage time and resources", + "clientCommunication": "Stay connected with clients", + "timeTracking": "Monitor project hours", + "other": "Something else" + } } diff --git a/worklenz-frontend/public/locales/en/admin-center/configuration.json b/worklenz-frontend/public/locales/en/admin-center/configuration.json new file mode 100644 index 000000000..782932390 --- /dev/null +++ b/worklenz-frontend/public/locales/en/admin-center/configuration.json @@ -0,0 +1,26 @@ +{ + "billingDetails": "Billing Details", + "name": "Name", + "namePlaceholder": "Name", + "emailAddress": "Email Address", + "emailPlaceholder": "Email Address", + "contactNumber": "Contact Number", + "phoneNumberPlaceholder": "Phone Number", + "phoneValidationError": "Phone number must be exactly 10 digits", + "companyDetails": "Company Details", + "companyName": "Company Name", + "companyNamePlaceholder": "Company Name", + "addressLine01": "Address Line 01", + "addressLine01Placeholder": "Address Line 01", + "addressLine02": "Address Line 02", + "addressLine02Placeholder": "Address Line 02", + "country": "Country", + "countryPlaceholder": "Country", + "city": "City", + "cityPlaceholder": "City", + "state": "State", + "statePlaceholder": "State", + "postalCode": "Postal Code", + "postalCodePlaceholder": "Postal Code", + "save": "Save" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/en/admin-center/current-bill.json b/worklenz-frontend/public/locales/en/admin-center/current-bill.json index fe840789c..b47141c7e 100644 --- a/worklenz-frontend/public/locales/en/admin-center/current-bill.json +++ b/worklenz-frontend/public/locales/en/admin-center/current-bill.json @@ -106,6 +106,7 @@ "expirestoday": "today", "expirestomorrow": "tomorrow", + "expiredDayAgo": "{{days}} day ago", "expiredDaysAgo": "{{days}} days ago", "continueWith": "Continue with {{plan}}", @@ -117,5 +118,26 @@ "currentSeatsText": "You currently have {{seats}} seats available.", "selectSeatsText": "Please select the number of additional seats to purchase.", "purchase": "Purchase", - "contactSales": "Contact sales" + "contactSales": "Contact sales", + "submitSuccess": "Code redeemed successfully!", + "submitSuccessDescription": "Your account has been updated with the new credits.", + "percentUsed": "% Used", + "sizeUnits": { + "bytes": "Bytes", + "kb": "KB", + "mb": "MB", + "gb": "GB", + "tb": "TB" + }, + "seatPerMonth": "seat / month", + "totalPrice": "Total $", + "tryForFree": "Try for free", + "subscriptionUpdateSuccess": "Subscription updated successfully!", + "paymentProcessorError": "Failed to load payment processor", + "seatsLabel": "Seats:", + "requiredField": "*", + "purchaseSeatsTextSingle": "To continue, you'll need to purchase an additional seat.", + "singleUserNote": "You currently have 1 seat available.", + "selectSeatsTextSingle": "Please select the number of additional seats to purchase.", + "phoneNumberPattern": "07xxxxxxxx" } diff --git a/worklenz-frontend/public/locales/en/admin-center/overview.json b/worklenz-frontend/public/locales/en/admin-center/overview.json index efc42855f..3ec4329b3 100644 --- a/worklenz-frontend/public/locales/en/admin-center/overview.json +++ b/worklenz-frontend/public/locales/en/admin-center/overview.json @@ -4,5 +4,8 @@ "owner": "Organization Owner", "admins": "Organization Admins", "contactNumber": "Add Contact Number", - "edit": "Edit" + "edit": "Edit", + "emailAddress": "Email Address", + "enterOrganizationName": "Enter organization name", + "ownerSuffix": " (Owner)" } diff --git a/worklenz-frontend/public/locales/en/admin-center/users.json b/worklenz-frontend/public/locales/en/admin-center/users.json index 7e462ef6f..db2641a43 100644 --- a/worklenz-frontend/public/locales/en/admin-center/users.json +++ b/worklenz-frontend/public/locales/en/admin-center/users.json @@ -5,5 +5,6 @@ "user": "User", "email": "Email", "lastActivity": "Last Activity", - "refresh": "Refresh users" + "refresh": "Refresh users", + "name": "Name" } diff --git a/worklenz-frontend/public/locales/en/common.json b/worklenz-frontend/public/locales/en/common.json index 815560beb..22b224847 100644 --- a/worklenz-frontend/public/locales/en/common.json +++ b/worklenz-frontend/public/locales/en/common.json @@ -5,5 +5,50 @@ "signup-failed": "Signup failed. Please ensure all required fields are filled and try again.", "reconnecting": "Disconnected from server.", "connection-lost": "Failed to connect to server. Please check your internet connection.", - "connection-restored": "Connected to server successfully" + "connection-restored": "Connected to server successfully", + "cancel": "Cancel", + "update-available": "Worklenz Updated!", + "update-description": "A new version of Worklenz is available with the latest features and improvements.", + "update-instruction": "To get the best experience, please reload the page to apply the new changes.", + "update-whats-new": "💡 <1>What's new: Enhanced performance, bug fixes, and improved user experience", + "update-now": "Update Now", + "update-later": "Later", + "updating": "Updating...", + "license-expired-title": "Subscription Expired", + "license-expired-subtitle": "Your Worklenz subscription has ended. Please renew to continue enjoying all features.", + "license-expired-trial-title": "Trial Period Expired", + "license-expired-trial-subtitle": "Your Worklenz trial has ended. Please upgrade to continue enjoying all features.", + "license-expired-custom-title": "Custom Plan Expired", + "license-expired-custom-subtitle": "Your custom plan has expired. Please contact support or renew to continue.", + "license-expired-features": "Renew now to continue enjoying:", + "license-expired-trial-features": "Upgrade now to unlock:", + "license-expired-custom-features": "Contact support to continue with:", + "license-expired-feature-1": "✓ Unlimited projects and tasks", + "license-expired-feature-2": "✓ Advanced reporting and analytics", + "license-expired-feature-3": "✓ Team collaboration features", + "license-expired-feature-4": "✓ Priority support", + "license-expired-upgrade": "Renew Now", + "license-expired-trial-upgrade": "Upgrade Now", + "license-expired-custom-upgrade": "Contact Support", + "license-expired-contacting-support": "Contacting Support...", + "license-expired-message-sent": "Message Sent ✓", + "license-expired-days-remaining": "{{days}} days remaining in your trial", + "trial-expiring-soon": "Your trial expires in {{days}} day", + "trial-expiring-soon_plural": "Your trial expires in {{days}} days", + "trial-expiring-today": "Your trial expires today!", + "trial-expiring-upgrade": "Upgrade now to keep all your data and continue without interruption", + "trial-badge-days": "{{days}}d left", + "trial-badge-today": "Last day!", + "trial-badge-hours": "{{hours}}h left", + "trial-alert-admin-note": "You can still access the Admin Center to manage your subscription", + "trial-alert-dismiss": "Dismiss for today", + "license-expiring-soon": "Your license expires in {{days}} day", + "license-expiring-soon_plural": "Your license expires in {{days}} days", + "license-expiring-today": "Your license expires today!", + "license-expired-grace-period": "Your license has expired. {{days}} day grace period remaining", + "license-expired-grace-period_plural": "Your license has expired. {{days}} days grace period remaining", + "license-expiring-upgrade": "Renew now to keep all your data and continue without interruption", + "license-badge-days": "{{days}}d left", + "license-badge-today": "Last day!", + "license-badge-hours": "{{hours}}h left" } diff --git a/worklenz-frontend/public/locales/en/home.json b/worklenz-frontend/public/locales/en/home.json index ccf40936e..cfd513107 100644 --- a/worklenz-frontend/public/locales/en/home.json +++ b/worklenz-frontend/public/locales/en/home.json @@ -41,6 +41,22 @@ "list": "List", "calendar": "Calendar", "tasks": "Tasks", - "refresh": "Refresh" + "refresh": "Refresh", + "recentActivity": "Recent Activity", + "recentTasks": "Recent Tasks", + "recentTasksSegment": "Recent Tasks", + "timeLogged": "Time Logged", + "timeLoggedSegment": "Time Logged", + "noRecentTasks": "No recent tasks", + "noTimeLoggedTasks": "No time logged tasks", + "activityTag": "Activity", + "timeLogTag": "Time Log", + "timerTag": "Timer", + "activitySingular": "activity", + "activityPlural": "activities", + "recentTaskAriaLabel": "Recent task:", + "timeLoggedTaskAriaLabel": "Time logged task:", + "errorLoadingRecentTasks": "Error loading recent tasks", + "errorLoadingTimeLoggedTasks": "Error loading time logged tasks" } } diff --git a/worklenz-frontend/public/locales/en/kanban-board.json b/worklenz-frontend/public/locales/en/kanban-board.json index e295a6c68..776591522 100644 --- a/worklenz-frontend/public/locales/en/kanban-board.json +++ b/worklenz-frontend/public/locales/en/kanban-board.json @@ -10,6 +10,17 @@ "deleteConfirmationOk": "Yes", "deleteConfirmationCancel": "Cancel", + "deleteTaskTitle": "Delete Task", + "deleteTaskContent": "Are you sure you want to delete this task? This action cannot be undone.", + "deleteTaskConfirm": "Delete", + "deleteTaskCancel": "Cancel", + + "deleteStatusTitle": "Delete Status", + "deleteStatusContent": "Are you sure you want to delete this status? This action cannot be undone.", + + "deletePhaseTitle": "Delete Phase", + "deletePhaseContent": "Are you sure you want to delete this phase? This action cannot be undone.", + "dueDate": "Due date", "cancel": "Cancel", @@ -29,5 +40,14 @@ "nextWeek": "Next week", "noSubtasks": "No subtasks", "showSubtasks": "Show subtasks", - "hideSubtasks": "Hide subtasks" + "hideSubtasks": "Hide subtasks", + + "errorLoadingTasks": "Error loading tasks", + "noTasksFound": "No tasks found", + "loadingFilters": "Loading filters...", + "failedToUpdateColumnOrder": "Failed to update column order", + "failedToUpdatePhaseOrder": "Failed to update phase order", + "pleaseTryAgain": "Please try again", + "taskNotCompleted": "Task is not completed", + "completeTaskDependencies": "Please complete the task dependencies before proceeding" } diff --git a/worklenz-frontend/public/locales/en/navbar.json b/worklenz-frontend/public/locales/en/navbar.json index e7e22cb30..a63de500b 100644 --- a/worklenz-frontend/public/locales/en/navbar.json +++ b/worklenz-frontend/public/locales/en/navbar.json @@ -18,6 +18,7 @@ "profileTooltip": "View profile", "adminCenter": "Admin Center", "settings": "Settings", + "deleteAccount": "Delete Account", "logOut": "Log Out", "notificationsDrawer": { "read": "Read notifications", diff --git a/worklenz-frontend/public/locales/en/project-view-members.json b/worklenz-frontend/public/locales/en/project-view-members.json index 6ed8ddf0c..fd15ca71b 100644 --- a/worklenz-frontend/public/locales/en/project-view-members.json +++ b/worklenz-frontend/public/locales/en/project-view-members.json @@ -13,5 +13,6 @@ "deleteButtonTooltip": "Remove from project", "memberCount": "Member", "membersCountPlural": "Members", - "emptyText": "There are no attachments in the project." + "emptyText": "There are no attachments in the project.", + "searchPlaceholder": "Search members" } diff --git a/worklenz-frontend/public/locales/en/project-view/project-member-drawer.json b/worklenz-frontend/public/locales/en/project-view/project-member-drawer.json index ad2d60c8c..29262250b 100644 --- a/worklenz-frontend/public/locales/en/project-view/project-member-drawer.json +++ b/worklenz-frontend/public/locales/en/project-view/project-member-drawer.json @@ -1,7 +1,11 @@ { - "title": "Project Members", + "title": "Share Project", "searchLabel": "Add members by adding their name or email", "searchPlaceholder": "Type name or email", "inviteAsAMember": "Invite as a member", - "inviteNewMemberByEmail": "Invite new member by email" + "inviteNewMemberByEmail": "Invite new member by email", + "members": "Members", + "copyProjectLink": "Copy project link", + "inviteMember": "Invite Member", + "alsoInviteToProject": "Also invite to project" } diff --git a/worklenz-frontend/public/locales/en/settings/account-deletion.json b/worklenz-frontend/public/locales/en/settings/account-deletion.json new file mode 100644 index 000000000..13a2e80b7 --- /dev/null +++ b/worklenz-frontend/public/locales/en/settings/account-deletion.json @@ -0,0 +1,29 @@ +{ + "title": "Account Deletion", + "dangerZone": "Danger Zone", + "warningTitle": "Warning: This action cannot be undone", + "warningDescription1": "Deleting your account will:", + "warningPoint1": "Permanently delete all your personal data", + "warningPoint2": "Remove you from all teams and projects", + "warningPoint3": "Delete all your created content and contributions", + "warningPoint4": "Cancel any active subscriptions", + "warningDescription2": "Your data will be permanently deleted within 30 days of your request.", + "beforeDeletion": "Before deleting your account, please:", + "exportData": "Export any important data you wish to keep", + "cancelSubscription": "Ensure all subscriptions are cancelled", + "informTeam": "Inform your team members about your departure", + "deleteAccountButton": "Delete My Account", + "confirmDeletionTitle": "Confirm Account Deletion", + "finalWarning": "This action is permanent and cannot be reversed!", + "confirmationInstructions": "To confirm deletion, please type the word below exactly as shown:", + "typeDeleteToConfirm": "Type DELETE to confirm", + "confirmDelete": "Yes, Delete My Account", + "cancel": "Cancel", + "invalidConfirmation": "Please type DELETE to confirm", + "deletionRequestSuccess": "Account deletion request submitted successfully", + "requestSubmitted": "Request Submitted", + "deletionConfirmationMessage": "Your account deletion request has been received. Your account and all associated data will be permanently deleted within 30 days. You will now be logged out.", + "deletionRequestFailed": "Failed to submit deletion request. Please try again.", + "dataRetentionNotice": "Data Retention Notice", + "dataRetentionDescription": "As per our data retention policy, your account and all associated data will be permanently deleted within 30 days. This process cannot be reversed once initiated." +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/en/settings/labels.json b/worklenz-frontend/public/locales/en/settings/labels.json index 5c3d24799..4e1e173c1 100644 --- a/worklenz-frontend/public/locales/en/settings/labels.json +++ b/worklenz-frontend/public/locales/en/settings/labels.json @@ -7,5 +7,9 @@ "searchPlaceholder": "Search by name", "emptyText": "Labels can be created while updating or creating tasks.", "pinTooltip": "Click to pin this into the main menu", - "colorChangeTooltip": "Click to change color" + "colorChangeTooltip": "Click to change color", + "pageTitle": "Manage Labels", + "deleteConfirmTitle": "Are you sure you want to delete this?", + "deleteButton": "Delete", + "cancelButton": "Cancel" } diff --git a/worklenz-frontend/public/locales/en/settings/sidebar.json b/worklenz-frontend/public/locales/en/settings/sidebar.json index d0b648299..5ab7b3ada 100644 --- a/worklenz-frontend/public/locales/en/settings/sidebar.json +++ b/worklenz-frontend/public/locales/en/settings/sidebar.json @@ -11,5 +11,6 @@ "teams": "Teams", "change-password": "Change Password", "language-and-region": "Language and Region", - "appearance": "Appearance" + "appearance": "Appearance", + "account-deletion": "Delete Account" } diff --git a/worklenz-frontend/public/locales/en/settings/team-members.json b/worklenz-frontend/public/locales/en/settings/team-members.json index 36918b90a..d59f2cf21 100644 --- a/worklenz-frontend/public/locales/en/settings/team-members.json +++ b/worklenz-frontend/public/locales/en/settings/team-members.json @@ -19,7 +19,7 @@ "cancelText": "No, cancel", "deactivatedText": "(Currently deactivated)", "pendingInvitationText": "(Invitation pending)", - "addMemberDrawerTitle": "Add New Team Member", + "addMemberDrawerTitle": "Invite Team Members", "updateMemberDrawerTitle": "Update Team Member", "addMemberEmailHint": "Members will be added to the team regardless of invitation acceptance status", "memberEmailLabel": "Email(s)", @@ -28,7 +28,7 @@ "jobTitleLabel": "Job Title", "jobTitlePlaceholder": "Select or search job title (Optional)", "memberAccessLabel": "Access Level", - "addToTeamButton": "Add Member to Team", + "addToTeamButton": "Send Invitation", "updateButton": "Save Changes", "resendInvitationButton": "Resend Invitation Email", "invitationSentSuccessMessage": "Team invitation sent successfully!", @@ -43,5 +43,6 @@ "updatedText": "Updated", "noResultFound": "Type an email address and hit enter...", "jobTitlesFetchError": "Failed to fetch job titles", - "invitationResent": "Invitation resent successfully!" + "invitationResent": "Invitation resent successfully!", + "copyTeamLink": "Copy team link" } diff --git a/worklenz-frontend/public/locales/en/survey.json b/worklenz-frontend/public/locales/en/survey.json new file mode 100644 index 000000000..d6b13b3f2 --- /dev/null +++ b/worklenz-frontend/public/locales/en/survey.json @@ -0,0 +1,14 @@ +{ + "modalTitle": "Help Us Improve Your Experience", + "skip": "Skip for now", + "previous": "Previous", + "next": "Next", + "completeSurvey": "Complete Survey", + "submitting": "Submitting your responses...", + "submitSuccessTitle": "Thank you!", + "submitSuccessSubtitle": "Your feedback helps us improve Worklenz for everyone.", + "submitSuccessMessage": "Thank you for completing the survey!", + "submitErrorMessage": "Failed to submit survey. Please try again.", + "submitErrorLog": "Failed to submit survey", + "fetchErrorLog": "Failed to fetch survey" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/en/task-list-filters.json b/worklenz-frontend/public/locales/en/task-list-filters.json index 118ac4ce1..b104052c0 100644 --- a/worklenz-frontend/public/locales/en/task-list-filters.json +++ b/worklenz-frontend/public/locales/en/task-list-filters.json @@ -84,5 +84,12 @@ "close": "Close", "cannotMoveStatus": "Cannot Move Status", "cannotMoveStatusMessage": "Cannot move this status because it would leave the '{{categoryName}}' category empty. Each category must have at least one status.", - "ok": "OK" + "ok": "OK", + "clearSort": "Clear Sort", + "sortAscending": "Sort Ascending", + "sortDescending": "Sort Descending", + "sortByField": "Sort by {{field}}", + "ascendingOrder": "Ascending", + "descendingOrder": "Descending", + "currentSort": "Current sort: {{field}} {{order}}" } diff --git a/worklenz-frontend/public/locales/en/task-list-table.json b/worklenz-frontend/public/locales/en/task-list-table.json index abd97ca5e..f20fdadc0 100644 --- a/worklenz-frontend/public/locales/en/task-list-table.json +++ b/worklenz-frontend/public/locales/en/task-list-table.json @@ -57,6 +57,9 @@ "contextMenu": { "assignToMe": "Assign to me", + "copyLink": "Copy link to task", + "linkCopied": "Link copied to clipboard", + "linkCopyFailed": "Failed to copy link", "moveTo": "Move to", "unarchive": "Unarchive", "archive": "Archive", @@ -133,5 +136,11 @@ "dependencies": "Task has dependencies", "recurring": "Recurring task" } + }, + + "timer": { + "conflictTitle": "Timer Already Running", + "conflictMessage": "You have a timer running for \"{{taskName}}\" in project \"{{projectName}}\". Would you like to stop that timer and start a new one for this task?", + "stopAndStart": "Stop & Start New Timer" } } diff --git a/worklenz-frontend/public/locales/es/account-setup.json b/worklenz-frontend/public/locales/es/account-setup.json index 3f7b013ee..98694ba3d 100644 --- a/worklenz-frontend/public/locales/es/account-setup.json +++ b/worklenz-frontend/public/locales/es/account-setup.json @@ -3,7 +3,28 @@ "setupYourAccount": "Configura tu cuenta.", "organizationStepTitle": "Nombra tu organización", - "organizationStepLabel": "Elige un nombre para tu cuenta de Worklenz.", + "organizationStepWelcome": "¡Bienvenido a Worklenz!", + "organizationStepDescription": "Comencemos configurando tu organización. Este será el espacio de trabajo principal para tu equipo.", + "organizationStepLabel": "Nombre de la organización", + "organizationStepPlaceholder": "ej. Corporación Acme", + "organizationStepTooltip": "Este nombre aparecerá en tu espacio de trabajo y se puede cambiar más tarde en la configuración.", + "organizationStepNeedIdeas": "¿Necesitas ideas?", + "organizationStepUseDetected": "Usar detectado:", + "organizationStepCharacters": "caracteres", + "organizationStepGoodLength": "Buena longitud", + "organizationStepTooShort": "Demasiado corto", + "organizationStepNamingTips": "Consejos para nombrar", + "organizationStepTip1": "Manténlo simple y memorable", + "organizationStepTip2": "Refleja tu industria o valores", + "organizationStepTip3": "Piensa en el crecimiento futuro", + "organizationStepTip4": "Hazlo único y reconocible", + "organizationStepSuggestionsTitle": "Sugerencias de nombres", + "organizationStepCategory1": "Empresas tecnológicas", + "organizationStepCategory2": "Agencias creativas", + "organizationStepCategory3": "Consultoría", + "organizationStepCategory4": "Startups", + "organizationStepSuggestionsNote": "Estos son solo ejemplos para empezar. Elige algo que represente a tu organización.", + "organizationStepPrivacyNote": "El nombre de tu organización es privado y solo visible para los miembros de tu equipo.", "projectStepTitle": "Crea tu primer proyecto", "projectStepLabel": "¿En qué proyecto estás trabajando ahora?", @@ -24,9 +45,171 @@ "step3InputLabel": "Invitar por correo electrónico", "addAnother": "Agregar otro", "skipForNow": "Omitir por ahora", + "skipping": "Omitiendo...", "formTitle": "Crea tu primera tarea.", "step3Title": "Invita a tu equipo a trabajar", "maxMembers": " (Puedes invitar hasta 5 miembros)", - "maxTasks": " (Puedes crear hasta 5 tareas)" + "maxTasks": " (Puedes crear hasta 5 tareas)", + + "membersStepTitle": "Invita a tu equipo", + "membersStepDescription": "Añade miembros del equipo a \"{{organizationName}}\" y comienza a colaborar", + "memberPlaceholder": "Miembro del equipo {{index}} - Ingresa dirección de correo", + "validEmailAddress": "Dirección de correo válida", + "addAnotherTeamMember": "Añadir otro miembro del equipo ({{current}}/{{max}})", + "canInviteLater": "Siempre puedes invitar miembros del equipo más tarde", + "skipStepDescription": "¿No tienes direcciones de correo listas? ¡No hay problema! Puedes omitir este paso e invitar miembros del equipo desde tu panel de proyecto más tarde.", + + "orgCategoryTech": "Empresas Tecnológicas", + "orgCategoryCreative": "Agencias Creativas", + "orgCategoryConsulting": "Consultoría", + "orgCategoryStartups": "Startups", + "namingTip1": "Manténlo simple y memorable", + "namingTip2": "Refleja tu industria o valores", + "namingTip3": "Piensa en el crecimiento futuro", + "namingTip4": "Hazlo único y reconocible", + + "aboutYouTitle": "Cuéntanos sobre ti", + "aboutYouDescription": "Ayúdanos a personalizar tu experiencia", + "orgTypeQuestion": "¿Qué describe mejor tu organización?", + "userRoleQuestion": "¿Cuál es tu rol?", + + "yourNeedsTitle": "¿Cuáles son tus principales necesidades?", + "yourNeedsDescription": "Selecciona todas las que apliquen para ayudarnos a configurar tu espacio de trabajo", + "yourNeedsQuestion": "¿Cómo usarás principalmente Worklenz?", + "useCaseTaskOrg": "Organizar y hacer seguimiento de tareas", + "useCaseTeamCollab": "Trabajar juntos sin problemas", + "useCaseResourceMgmt": "Gestionar tiempo y recursos", + "useCaseClientComm": "Mantenerse conectado con clientes", + "useCaseTimeTrack": "Monitorear horas de proyecto", + "useCaseOther": "Algo más", + "selectedText": "seleccionado", + "previousToolsQuestion": "¿Qué herramientas has usado antes? (Opcional)", + "previousToolsPlaceholder": "ej., Asana, Trello, Jira, Monday.com, etc.", + + "discoveryTitle": "Una última cosa...", + "discoveryDescription": "Ayúdanos a entender cómo descubriste Worklenz", + "discoveryQuestion": "¿Cómo te enteraste de nosotros?", + "allSetTitle": "¡Ya estás listo!", + "allSetDescription": "Vamos a crear tu primer proyecto y comenzar con Worklenz", + "surveyCompleteTitle": "¡Gracias!", + "surveyCompleteDescription": "Tu retroalimentación nos ayuda a mejorar Worklenz para todos", + "aboutYouStepName": "Sobre ti", + "yourNeedsStepName": "Tus necesidades", + "discoveryStepName": "Descubrimiento", + "stepProgress": "Paso {step} de 3: {title}", + + "projectStepHeader": "Vamos a crear tu primer proyecto", + "projectStepSubheader": "Empieza desde cero o usa una plantilla para ir más rápido", + "startFromScratch": "Empezar desde cero", + "templateSelected": "Plantilla seleccionada abajo", + "quickSuggestions": "Sugerencias rápidas:", + "orText": "O", + "startWithTemplate": "Comenzar con una plantilla", + "clearToSelectTemplate": "Borra el nombre del proyecto arriba para seleccionar una plantilla", + "templateHeadStart": "Obtén una ventaja inicial con estructuras de proyecto pre-construidas", + "browseAllTemplates": "Explorar todas las plantillas", + "templatesAvailable": "15+ plantillas específicas de industria disponibles", + "chooseTemplate": "Elige una plantilla que coincida con tu tipo de proyecto", + "createProject": "Crear proyecto", + + "templateSoftwareDev": "Desarrollo de Software", + "templateSoftwareDesc": "Sprints ágiles, seguimiento de errores, lanzamientos", + "templateMarketing": "Campaña de Marketing", + "templateMarketingDesc": "Planificación de campaña, calendario de contenido", + "templateConstruction": "Proyecto de Construcción", + "templateConstructionDesc": "Fases, permisos, contratistas", + "templateStartup": "Lanzamiento de Startup", + "templateStartupDesc": "Desarrollo MVP, financiación, crecimiento", + + "tasksStepTitle": "Añade tus primeras tareas", + "tasksStepDescription": "Desglosa \"{{projectName}}\" en tareas accionables para comenzar", + "taskPlaceholder": "Tarea {{index}} - ej., ¿Qué necesita hacerse?", + "addAnotherTask": "Añadir otra tarea ({{current}}/{{max}})", + + "surveyStepTitle": "Cuéntanos sobre ti", + "surveyStepLabel": "Ayúdanos a personalizar tu experiencia de Worklenz respondiendo algunas preguntas.", + + "organizationType": "¿Qué describe mejor tu organización?", + "organizationTypeFreelancer": "Freelancer", + "organizationTypeStartup": "Startup", + "organizationTypeSmallMediumBusiness": "Pequeña o Mediana Empresa", + "organizationTypeAgency": "Agencia", + "organizationTypeEnterprise": "Empresa", + "organizationTypeOther": "Otro", + + "userRole": "¿Cuál es tu rol?", + "userRoleFounderCeo": "Fundador / CEO", + "userRoleProjectManager": "Gerente de Proyecto", + "userRoleSoftwareDeveloper": "Desarrollador de Software", + "userRoleDesigner": "Diseñador", + "userRoleOperations": "Operaciones", + "userRoleOther": "Otro", + + "mainUseCases": "¿Para qué usarás principalmente Worklenz?", + "mainUseCasesTaskManagement": "Gestión de tareas", + "mainUseCasesTeamCollaboration": "Colaboración de equipo", + "mainUseCasesResourcePlanning": "Planificación de recursos", + "mainUseCasesClientCommunication": "Comunicación con clientes e informes", + "mainUseCasesTimeTracking": "Seguimiento de tiempo", + "mainUseCasesOther": "Otro", + + "previousTools": "¿Qué herramienta(s) usabas antes de Worklenz?", + "previousToolsPlaceholder": "ej. Trello, Asana, Monday.com", + + "howHeardAbout": "¿Cómo conociste Worklenz?", + "howHeardAboutGoogleSearch": "Búsqueda de Google", + "howHeardAboutTwitter": "Twitter", + "howHeardAboutLinkedin": "LinkedIn", + "howHeardAboutFriendColleague": "Un amigo o colega", + "howHeardAboutBlogArticle": "Un blog o artículo", + "howHeardAboutOther": "Otro", + + "aboutYouStepTitle": "Cuéntanos sobre ti", + "aboutYouStepDescription": "Ayúdanos a personalizar tu experiencia", + "yourNeedsStepTitle": "¿Cuáles son tus principales necesidades?", + "yourNeedsStepDescription": "Selecciona todas las que apliquen para ayudarnos a configurar tu espacio de trabajo", + "selected": "seleccionado", + "previousToolsLabel": "¿Qué herramientas has usado antes? (Opcional)", + + "roleSuggestions": { + "designer": "UI/UX, Gráficos, Creativo", + "developer": "Frontend, Backend, Full-stack", + "projectManager": "Planificación, Coordinación", + "marketing": "Contenido, Redes Sociales, Crecimiento", + "sales": "Desarrollo de Negocios, Relaciones con Clientes", + "operations": "Administración, RRHH, Finanzas" + }, + + "languages": { + "en": "English", + "es": "Español", + "pt": "Português", + "de": "Deutsch", + "alb": "Shqip", + "zh": "简体中文" + }, + + "orgSuggestions": { + "tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"], + "creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"], + "consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"], + "startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"] + }, + + "projectSuggestions": { + "freelancer": ["Proyecto Cliente", "Actualización Portfolio", "Marca Personal"], + "startup": ["Desarrollo MVP", "Lanzamiento Producto", "Investigación Mercado"], + "agency": ["Campaña Cliente", "Estrategia Marca", "Rediseño Website"], + "enterprise": ["Migración Sistema", "Optimización Procesos", "Capacitación Equipo"] + }, + + "useCaseDescriptions": { + "taskManagement": "Organizar y rastrear tareas", + "teamCollaboration": "Trabajar juntos sin problemas", + "resourcePlanning": "Gestionar tiempo y recursos", + "clientCommunication": "Mantenerse conectado con clientes", + "timeTracking": "Monitorear horas de proyecto", + "other": "Algo más" + } } diff --git a/worklenz-frontend/public/locales/es/admin-center/current-bill.json b/worklenz-frontend/public/locales/es/admin-center/current-bill.json index 52a4bdbba..ae332a769 100644 --- a/worklenz-frontend/public/locales/es/admin-center/current-bill.json +++ b/worklenz-frontend/public/locales/es/admin-center/current-bill.json @@ -101,6 +101,7 @@ "expirestoday": "hoy", "expirestomorrow": "mañana", + "expiredDayAgo": "hace {{days}} día", "expiredDaysAgo": "hace {{days}} días", "creditPlan": "Plan de Crédito", "customPlan": "Plan Personalizado", diff --git a/worklenz-frontend/public/locales/es/auth/signup.json b/worklenz-frontend/public/locales/es/auth/signup.json index 2dbd01884..768a3952c 100644 --- a/worklenz-frontend/public/locales/es/auth/signup.json +++ b/worklenz-frontend/public/locales/es/auth/signup.json @@ -7,12 +7,12 @@ "emailLabel": "Correo electrónico", "emailPlaceholder": "Ingresa tu correo electrónico", "emailRequired": "¡Por favor ingresa tu correo electrónico!", - "passwordLabel": "Password", - "passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.", - "passwordPlaceholder": "Enter your password", + "passwordLabel": "Contraseña", + "passwordGuideline": "La contraseña debe tener al menos 8 caracteres, incluir letras mayúsculas y minúsculas, un número y un carácter especial.", + "passwordPlaceholder": "Ingresa tu contraseña", "passwordRequired": "¡Por favor ingresa tu contraseña!", "passwordMinCharacterRequired": "¡La contraseña debe tener al menos 8 caracteres!", - "passwordMaxCharacterRequired": "Password must be at most 32 characters!", + "passwordMaxCharacterRequired": "¡La contraseña debe tener como máximo 32 caracteres!", "passwordPatternRequired": "¡La contraseña no cumple con los requisitos!", "strongPasswordPlaceholder": "Ingresa una contraseña más segura", "passwordValidationAltText": "La contraseña debe incluir al menos 8 caracteres con letras mayúsculas y minúsculas, un número y un símbolo.", diff --git a/worklenz-frontend/public/locales/es/common.json b/worklenz-frontend/public/locales/es/common.json index 583e86709..d9b65732b 100644 --- a/worklenz-frontend/public/locales/es/common.json +++ b/worklenz-frontend/public/locales/es/common.json @@ -5,5 +5,50 @@ "signup-failed": "Error al registrarse. Por favor asegúrate de llenar todos los campos requeridos e intenta nuevamente.", "reconnecting": "Reconectando al servidor...", "connection-lost": "Conexión perdida. Intentando reconectarse...", - "connection-restored": "Conexión restaurada. Reconectando al servidor..." + "connection-restored": "Conexión restaurada. Reconectando al servidor...", + "cancel": "Cancelar", + "update-available": "¡Worklenz actualizado!", + "update-description": "Una nueva versión de Worklenz está disponible con las últimas funciones y mejoras.", + "update-instruction": "Para obtener la mejor experiencia, por favor recarga la página para aplicar los nuevos cambios.", + "update-whats-new": "💡 <1>Qué hay de nuevo: Rendimiento mejorado, correcciones de errores y experiencia de usuario mejorada", + "update-now": "Actualizar ahora", + "update-later": "Más tarde", + "updating": "Actualizando...", + "license-expired-title": "Suscripción expirada", + "license-expired-subtitle": "Su suscripción de Worklenz ha terminado. Por favor renueve para continuar disfrutando de todas las funciones.", + "license-expired-trial-title": "Período de prueba expirado", + "license-expired-trial-subtitle": "Su período de prueba de Worklenz ha terminado. Por favor actualice para continuar disfrutando de todas las funciones.", + "license-expired-custom-title": "Plan personalizado expirado", + "license-expired-custom-subtitle": "Su plan personalizado ha expirado. Por favor contacte soporte o renueve para continuar.", + "license-expired-features": "Renueve ahora para continuar disfrutando:", + "license-expired-trial-features": "Actualice ahora para desbloquear:", + "license-expired-custom-features": "Contacte soporte para continuar con:", + "license-expired-feature-1": "✓ Proyectos y tareas ilimitados", + "license-expired-feature-2": "✓ Informes y análisis avanzados", + "license-expired-feature-3": "✓ Funciones de colaboración en equipo", + "license-expired-feature-4": "✓ Soporte prioritario", + "license-expired-upgrade": "Renovar ahora", + "license-expired-trial-upgrade": "Actualizar ahora", + "license-expired-custom-upgrade": "Contactar soporte", + "license-expired-contacting-support": "Contactando soporte...", + "license-expired-message-sent": "Mensaje enviado ✓", + "license-expired-days-remaining": "{{days}} días restantes en su período de prueba", + "trial-expiring-soon": "Su período de prueba expira en {{days}} día", + "trial-expiring-soon_plural": "Su período de prueba expira en {{days}} días", + "trial-expiring-today": "¡Su período de prueba expira hoy!", + "trial-expiring-upgrade": "Actualice ahora para conservar todos sus datos y continuar sin interrupciones", + "trial-badge-days": "{{days}}d restantes", + "trial-badge-today": "¡Último día!", + "trial-badge-hours": "{{hours}}h restantes", + "trial-alert-admin-note": "Aún puede acceder al Centro de administración para gestionar su suscripción", + "trial-alert-dismiss": "Descartar por hoy", + "license-expiring-soon": "Su licencia expira en {{days}} día", + "license-expiring-soon_plural": "Su licencia expira en {{days}} días", + "license-expiring-today": "¡Su licencia expira hoy!", + "license-expired-grace-period": "Su licencia ha expirado. {{days}} día de período de gracia restante", + "license-expired-grace-period_plural": "Su licencia ha expirado. {{days}} días de período de gracia restantes", + "license-expiring-upgrade": "Renueve ahora para conservar todos sus datos y continuar sin interrupciones", + "license-badge-days": "{{days}}d restantes", + "license-badge-today": "¡Último día!", + "license-badge-hours": "{{hours}}h restantes" } diff --git a/worklenz-frontend/public/locales/es/home.json b/worklenz-frontend/public/locales/es/home.json index cfd238f94..c22061480 100644 --- a/worklenz-frontend/public/locales/es/home.json +++ b/worklenz-frontend/public/locales/es/home.json @@ -40,6 +40,22 @@ "list": "Lista", "calendar": "Calendario", "tasks": "Tareas", - "refresh": "Actualizar" + "refresh": "Actualizar", + "recentActivity": "Actividad Reciente", + "recentTasks": "Tareas Recientes", + "recentTasksSegment": "Tareas Recientes", + "timeLogged": "Tiempo Registrado", + "timeLoggedSegment": "Tiempo Registrado", + "noRecentTasks": "No hay tareas recientes", + "noTimeLoggedTasks": "No hay tareas con tiempo registrado", + "activityTag": "Actividad", + "timeLogTag": "Registro de Tiempo", + "timerTag": "Temporizador", + "activitySingular": "actividad", + "activityPlural": "actividades", + "recentTaskAriaLabel": "Tarea reciente:", + "timeLoggedTaskAriaLabel": "Tarea con tiempo registrado:", + "errorLoadingRecentTasks": "Error al cargar tareas recientes", + "errorLoadingTimeLoggedTasks": "Error al cargar tareas con tiempo registrado" } } diff --git a/worklenz-frontend/public/locales/es/kanban-board.json b/worklenz-frontend/public/locales/es/kanban-board.json index 6e8d59756..df4f2b1ea 100644 --- a/worklenz-frontend/public/locales/es/kanban-board.json +++ b/worklenz-frontend/public/locales/es/kanban-board.json @@ -10,6 +10,17 @@ "deleteConfirmationOk": "Sí", "deleteConfirmationCancel": "Cancelar", + "deleteTaskTitle": "Eliminar tarea", + "deleteTaskContent": "¿Estás seguro de que deseas eliminar esta tarea? Esta acción no se puede deshacer.", + "deleteTaskConfirm": "Eliminar", + "deleteTaskCancel": "Cancelar", + + "deleteStatusTitle": "Eliminar estado", + "deleteStatusContent": "¿Estás seguro de que deseas eliminar este estado? Esta acción no se puede deshacer.", + + "deletePhaseTitle": "Eliminar fase", + "deletePhaseContent": "¿Estás seguro de que deseas eliminar esta fase? Esta acción no se puede deshacer.", + "dueDate": "Fecha de vencimiento", "cancel": "Cancelar", @@ -26,5 +37,17 @@ "noDueDate": "Sin fecha de vencimiento", "save": "Guardar", "clear": "Limpiar", - "nextWeek": "Próxima semana" + "nextWeek": "Próxima semana", + "noSubtasks": "Sin subtareas", + "showSubtasks": "Mostrar subtareas", + "hideSubtasks": "Ocultar subtareas", + + "errorLoadingTasks": "Error al cargar tareas", + "noTasksFound": "No se encontraron tareas", + "loadingFilters": "Cargando filtros...", + "failedToUpdateColumnOrder": "Error al actualizar el orden de las columnas", + "failedToUpdatePhaseOrder": "Error al actualizar el orden de las fases", + "pleaseTryAgain": "Por favor, inténtalo de nuevo", + "taskNotCompleted": "La tarea no está completada", + "completeTaskDependencies": "Por favor, completa las dependencias de la tarea antes de continuar" } diff --git a/worklenz-frontend/public/locales/es/navbar.json b/worklenz-frontend/public/locales/es/navbar.json index 97c79d509..e7d2e5ec9 100644 --- a/worklenz-frontend/public/locales/es/navbar.json +++ b/worklenz-frontend/public/locales/es/navbar.json @@ -18,6 +18,7 @@ "profileTooltip": "Ver perfil", "adminCenter": "Centro de administración", "settings": "Configuración", + "deleteAccount": "Eliminar Cuenta", "logOut": "Cerrar sesión", "notificationsDrawer": { "read": "Notificaciones leídas", diff --git a/worklenz-frontend/public/locales/es/project-view-members.json b/worklenz-frontend/public/locales/es/project-view-members.json index 95a8d9430..46f26b3f9 100644 --- a/worklenz-frontend/public/locales/es/project-view-members.json +++ b/worklenz-frontend/public/locales/es/project-view-members.json @@ -13,5 +13,6 @@ "deleteButtonTooltip": "Eliminar del proyecto", "memberCount": "Miembro", "membersCountPlural": "Miembros", - "emptyText": "No hay archivos adjuntos en el proyecto." + "emptyText": "No hay archivos adjuntos en el proyecto.", + "searchPlaceholder": "Buscar miembros" } diff --git a/worklenz-frontend/public/locales/es/project-view/project-member-drawer.json b/worklenz-frontend/public/locales/es/project-view/project-member-drawer.json index ab7570fd9..2ade994ef 100644 --- a/worklenz-frontend/public/locales/es/project-view/project-member-drawer.json +++ b/worklenz-frontend/public/locales/es/project-view/project-member-drawer.json @@ -3,5 +3,9 @@ "searchLabel": "Agregar miembros ingresando su nombre o correo electrónico", "searchPlaceholder": "Escriba nombre o correo electrónico", "inviteAsAMember": "Invitar como miembro", - "inviteNewMemberByEmail": "Invitar nuevo miembro por correo electrónico" + "inviteNewMemberByEmail": "Invitar nuevo miembro por correo electrónico", + "members": "Miembros", + "copyProjectLink": "Copiar enlace del proyecto", + "inviteMember": "Invitar miembro", + "alsoInviteToProject": "También invitar al proyecto" } diff --git a/worklenz-frontend/public/locales/es/settings/labels.json b/worklenz-frontend/public/locales/es/settings/labels.json index 22cd95329..fa0f3364f 100644 --- a/worklenz-frontend/public/locales/es/settings/labels.json +++ b/worklenz-frontend/public/locales/es/settings/labels.json @@ -7,5 +7,9 @@ "searchPlaceholder": "Buscar por nombre", "emptyText": "Las etiquetas se pueden crear al actualizar o crear tareas.", "pinTooltip": "Haz clic para fijar esto en el menú principal", - "colorChangeTooltip": "Haz clic para cambiar el color" + "colorChangeTooltip": "Haz clic para cambiar el color", + "pageTitle": "Administrar Etiquetas", + "deleteConfirmTitle": "¿Estás seguro de que quieres eliminar esto?", + "deleteButton": "Eliminar", + "cancelButton": "Cancelar" } diff --git a/worklenz-frontend/public/locales/es/settings/team-members.json b/worklenz-frontend/public/locales/es/settings/team-members.json index 1000bf98c..7f317fab6 100644 --- a/worklenz-frontend/public/locales/es/settings/team-members.json +++ b/worklenz-frontend/public/locales/es/settings/team-members.json @@ -28,7 +28,7 @@ "jobTitleLabel": "Cargo", "jobTitlePlaceholder": "Seleccione o busque cargo (Opcional)", "memberAccessLabel": "Nivel de acceso", - "addToTeamButton": "Agregar miembro al equipo", + "addToTeamButton": "Enviar invitación", "updateButton": "Guardar cambios", "resendInvitationButton": "Reenviar correo de invitación", "invitationSentSuccessMessage": "¡Invitación al equipo enviada exitosamente!", @@ -43,5 +43,6 @@ "updatedText": "Actualizado", "noResultFound": "Escriba una dirección de correo electrónico y presione enter...", "jobTitlesFetchError": "Error al obtener los cargos", - "invitationResent": "¡Invitación reenviada exitosamente!" + "invitationResent": "¡Invitación reenviada exitosamente!", + "copyTeamLink": "Copiar enlace del equipo" } diff --git a/worklenz-frontend/public/locales/es/survey.json b/worklenz-frontend/public/locales/es/survey.json new file mode 100644 index 000000000..be30d7555 --- /dev/null +++ b/worklenz-frontend/public/locales/es/survey.json @@ -0,0 +1,14 @@ +{ + "modalTitle": "Ayúdanos a mejorar tu experiencia", + "skip": "Omitir por ahora", + "previous": "Anterior", + "next": "Siguiente", + "completeSurvey": "Completar encuesta", + "submitting": "Enviando tus respuestas...", + "submitSuccessTitle": "¡Gracias!", + "submitSuccessSubtitle": "Tus comentarios nos ayudan a mejorar Worklenz para todos.", + "submitSuccessMessage": "¡Gracias por completar la encuesta!", + "submitErrorMessage": "No se pudo enviar la encuesta. Por favor, inténtalo de nuevo.", + "submitErrorLog": "Error al enviar la encuesta", + "fetchErrorLog": "Error al obtener la encuesta" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/task-list-filters.json b/worklenz-frontend/public/locales/es/task-list-filters.json index 00c27f169..6aa9a8a1a 100644 --- a/worklenz-frontend/public/locales/es/task-list-filters.json +++ b/worklenz-frontend/public/locales/es/task-list-filters.json @@ -84,5 +84,12 @@ "close": "Cerrar", "cannotMoveStatus": "No se puede mover el estado", "cannotMoveStatusMessage": "No se puede mover este estado porque dejaría vacía la categoría '{{categoryName}}'. Cada categoría debe tener al menos un estado.", - "ok": "OK" + "ok": "OK", + "clearSort": "Limpiar Ordenamiento", + "sortAscending": "Ordenar Ascendente", + "sortDescending": "Ordenar Descendente", + "sortByField": "Ordenar por {{field}}", + "ascendingOrder": "Ascendente", + "descendingOrder": "Descendente", + "currentSort": "Ordenamiento actual: {{field}} {{order}}" } diff --git a/worklenz-frontend/public/locales/es/task-list-table.json b/worklenz-frontend/public/locales/es/task-list-table.json index 779c76ede..53d99bb88 100644 --- a/worklenz-frontend/public/locales/es/task-list-table.json +++ b/worklenz-frontend/public/locales/es/task-list-table.json @@ -57,6 +57,9 @@ "contextMenu": { "assignToMe": "Asignar a mí", + "copyLink": "Copiar enlace a la tarea", + "linkCopied": "Enlace copiado al portapapeles", + "linkCopyFailed": "Error al copiar el enlace", "moveTo": "Mover a", "unarchive": "Desarchivar", "archive": "Archivar", @@ -133,5 +136,11 @@ "dependencies": "La tarea tiene dependencias", "recurring": "Tarea recurrente" } + }, + + "timer": { + "conflictTitle": "Temporizador Ya En Ejecución", + "conflictMessage": "Tiene un temporizador ejecutándose para \"{{taskName}}\" en el proyecto \"{{projectName}}\". ¿Le gustaría detener ese temporizador e iniciar uno nuevo para esta tarea?", + "stopAndStart": "Detener e Iniciar Nuevo Temporizador" } } diff --git a/worklenz-frontend/public/locales/pt/account-setup.json b/worklenz-frontend/public/locales/pt/account-setup.json index 1d8a8cba4..24e7cbe6f 100644 --- a/worklenz-frontend/public/locales/pt/account-setup.json +++ b/worklenz-frontend/public/locales/pt/account-setup.json @@ -3,7 +3,28 @@ "setupYourAccount": "Configure sua conta.", "organizationStepTitle": "Nomeie sua organização", - "organizationStepLabel": "Escolha um nome para sua conta Worklenz.", + "organizationStepWelcome": "Bem-vindo ao Worklenz!", + "organizationStepDescription": "Vamos começar configurando sua organização. Este será o espaço de trabalho principal para sua equipe.", + "organizationStepLabel": "Nome da organização", + "organizationStepPlaceholder": "ex. Corporação Acme", + "organizationStepTooltip": "Este nome aparecerá em seu espaço de trabalho e pode ser alterado posteriormente nas configurações.", + "organizationStepNeedIdeas": "Precisa de ideias?", + "organizationStepUseDetected": "Usar detectado:", + "organizationStepCharacters": "caracteres", + "organizationStepGoodLength": "Bom comprimento", + "organizationStepTooShort": "Muito curto", + "organizationStepNamingTips": "Dicas de nomenclatura", + "organizationStepTip1": "Mantenha simples e memorável", + "organizationStepTip2": "Reflita sua indústria ou valores", + "organizationStepTip3": "Pense no crescimento futuro", + "organizationStepTip4": "Torne único e marcante", + "organizationStepSuggestionsTitle": "Sugestões de nomes", + "organizationStepCategory1": "Empresas de tecnologia", + "organizationStepCategory2": "Agências criativas", + "organizationStepCategory3": "Consultoria", + "organizationStepCategory4": "Startups", + "organizationStepSuggestionsNote": "Estes são apenas exemplos para começar. Escolha algo que represente sua organização.", + "organizationStepPrivacyNote": "O nome da sua organização é privado e visível apenas para os membros da sua equipe.", "projectStepTitle": "Crie seu primeiro projeto", "projectStepLabel": "Em qual projeto você está trabalhando agora?", @@ -24,9 +45,171 @@ "step3InputLabel": "Convidar por email", "addAnother": "Adicionar outro", "skipForNow": "Pular por enquanto", + "skipping": "Pulando...", "formTitle": "Crie sua primeira tarefa.", "step3Title": "Convide sua equipe para trabalhar", "maxMembers": " (Você pode convidar até 5 membros)", - "maxTasks": " (Você pode criar até 5 tarefas)" + "maxTasks": " (Você pode criar até 5 tarefas)", + + "membersStepTitle": "Convide sua equipe", + "membersStepDescription": "Adicione membros da equipe ao \"{{organizationName}}\" e comece a colaborar", + "memberPlaceholder": "Membro da equipe {{index}} - Digite o endereço de email", + "validEmailAddress": "Endereço de email válido", + "addAnotherTeamMember": "Adicionar outro membro da equipe ({{current}}/{{max}})", + "canInviteLater": "Você sempre pode convidar membros da equipe mais tarde", + "skipStepDescription": "Não tem endereços de email prontos? Sem problema! Você pode pular esta etapa e convidar membros da equipe do seu painel de projeto mais tarde.", + + "orgCategoryTech": "Empresas de Tecnologia", + "orgCategoryCreative": "Agências Criativas", + "orgCategoryConsulting": "Consultoria", + "orgCategoryStartups": "Startups", + "namingTip1": "Mantenha simples e memorável", + "namingTip2": "Reflita sua indústria ou valores", + "namingTip3": "Pense no crescimento futuro", + "namingTip4": "Torne único e marcante", + + "aboutYouTitle": "Conte-nos sobre você", + "aboutYouDescription": "Ajude-nos a personalizar sua experiência", + "orgTypeQuestion": "O que melhor descreve sua organização?", + "userRoleQuestion": "Qual é seu papel?", + + "yourNeedsTitle": "Quais são suas principais necessidades?", + "yourNeedsDescription": "Selecione todas que se aplicam para nos ajudar a configurar seu espaço de trabalho", + "yourNeedsQuestion": "Como você usará principalmente o Worklenz?", + "useCaseTaskOrg": "Organizar e acompanhar tarefas", + "useCaseTeamCollab": "Trabalhar juntos perfeitamente", + "useCaseResourceMgmt": "Gerenciar tempo e recursos", + "useCaseClientComm": "Manter-se conectado com clientes", + "useCaseTimeTrack": "Monitorar horas do projeto", + "useCaseOther": "Algo mais", + "selectedText": "selecionado", + "previousToolsQuestion": "Que ferramentas você usou antes? (Opcional)", + "previousToolsPlaceholder": "ex., Asana, Trello, Jira, Monday.com, etc.", + + "discoveryTitle": "Uma última coisa...", + "discoveryDescription": "Ajude-nos a entender como você descobriu o Worklenz", + "discoveryQuestion": "Como você soube sobre nós?", + "allSetTitle": "Você está pronto!", + "allSetDescription": "Vamos criar seu primeiro projeto e começar com o Worklenz", + "surveyCompleteTitle": "Obrigado!", + "surveyCompleteDescription": "Seu feedback nos ajuda a melhorar o Worklenz para todos", + "aboutYouStepName": "Sobre você", + "yourNeedsStepName": "Suas necessidades", + "discoveryStepName": "Descoberta", + "stepProgress": "Passo {step} de 3: {title}", + + "projectStepHeader": "Vamos criar seu primeiro projeto", + "projectStepSubheader": "Comece do zero ou use um modelo para ir mais rápido", + "startFromScratch": "Começar do zero", + "templateSelected": "Modelo selecionado abaixo", + "quickSuggestions": "Sugestões rápidas:", + "orText": "OU", + "startWithTemplate": "Começar com um modelo", + "clearToSelectTemplate": "Limpe o nome do projeto acima para selecionar um modelo", + "templateHeadStart": "Obtenha uma vantagem inicial com estruturas de projeto pré-construídas", + "browseAllTemplates": "Navegar por todos os modelos", + "templatesAvailable": "15+ modelos específicos da indústria disponíveis", + "chooseTemplate": "Escolha um modelo que corresponda ao seu tipo de projeto", + "createProject": "Criar projeto", + + "templateSoftwareDev": "Desenvolvimento de Software", + "templateSoftwareDesc": "Sprints ágeis, rastreamento de bugs, lançamentos", + "templateMarketing": "Campanha de Marketing", + "templateMarketingDesc": "Planejamento de campanha, calendário de conteúdo", + "templateConstruction": "Projeto de Construção", + "templateConstructionDesc": "Fases, licenças, empreiteiros", + "templateStartup": "Lançamento de Startup", + "templateStartupDesc": "Desenvolvimento MVP, financiamento, crescimento", + + "tasksStepTitle": "Adicione suas primeiras tarefas", + "tasksStepDescription": "Divida \"{{projectName}}\" em tarefas acionáveis para começar", + "taskPlaceholder": "Tarefa {{index}} - ex., O que precisa ser feito?", + "addAnotherTask": "Adicionar outra tarefa ({{current}}/{{max}})", + + "surveyStepTitle": "Conte-nos sobre você", + "surveyStepLabel": "Ajude-nos a personalizar sua experiência no Worklenz respondendo algumas perguntas.", + + "organizationType": "O que melhor descreve sua organização?", + "organizationTypeFreelancer": "Freelancer", + "organizationTypeStartup": "Startup", + "organizationTypeSmallMediumBusiness": "Pequena ou Média Empresa", + "organizationTypeAgency": "Agência", + "organizationTypeEnterprise": "Empresa", + "organizationTypeOther": "Outro", + + "userRole": "Qual é o seu papel?", + "userRoleFounderCeo": "Fundador / CEO", + "userRoleProjectManager": "Gerente de Projeto", + "userRoleSoftwareDeveloper": "Desenvolvedor de Software", + "userRoleDesigner": "Designer", + "userRoleOperations": "Operações", + "userRoleOther": "Outro", + + "mainUseCases": "Para que você usará principalmente o Worklenz?", + "mainUseCasesTaskManagement": "Gerenciamento de tarefas", + "mainUseCasesTeamCollaboration": "Colaboração em equipe", + "mainUseCasesResourcePlanning": "Planejamento de recursos", + "mainUseCasesClientCommunication": "Comunicação com clientes e relatórios", + "mainUseCasesTimeTracking": "Controle de tempo", + "mainUseCasesOther": "Outro", + + "previousTools": "Que ferramenta(s) você usava antes do Worklenz?", + "previousToolsPlaceholder": "ex. Trello, Asana, Monday.com", + + "howHeardAbout": "Como você soube do Worklenz?", + "howHeardAboutGoogleSearch": "Busca no Google", + "howHeardAboutTwitter": "Twitter", + "howHeardAboutLinkedin": "LinkedIn", + "howHeardAboutFriendColleague": "Um amigo ou colega", + "howHeardAboutBlogArticle": "Um blog ou artigo", + "howHeardAboutOther": "Outro", + + "aboutYouStepTitle": "Conte-nos sobre você", + "aboutYouStepDescription": "Ajude-nos a personalizar sua experiência", + "yourNeedsStepTitle": "Quais são suas principais necessidades?", + "yourNeedsStepDescription": "Selecione todas que se aplicam para nos ajudar a configurar seu espaço de trabalho", + "selected": "selecionado", + "previousToolsLabel": "Que ferramentas você usou antes? (Opcional)", + + "roleSuggestions": { + "designer": "UI/UX, Gráficos, Criativo", + "developer": "Frontend, Backend, Full-stack", + "projectManager": "Planejamento, Coordenação", + "marketing": "Conteúdo, Mídias Sociais, Crescimento", + "sales": "Desenvolvimento de Negócios, Relacionamento com Clientes", + "operations": "Administração, RH, Finanças" + }, + + "languages": { + "en": "English", + "es": "Español", + "pt": "Português", + "de": "Deutsch", + "alb": "Shqip", + "zh": "简体中文" + }, + + "orgSuggestions": { + "tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"], + "creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"], + "consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"], + "startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"] + }, + + "projectSuggestions": { + "freelancer": ["Projeto Cliente", "Atualização Portfolio", "Marca Pessoal"], + "startup": ["Desenvolvimento MVP", "Lançamento Produto", "Pesquisa Mercado"], + "agency": ["Campanha Cliente", "Estratégia Marca", "Redesign Website"], + "enterprise": ["Migração Sistema", "Otimização Processos", "Treinamento Equipe"] + }, + + "useCaseDescriptions": { + "taskManagement": "Organizar e rastrear tarefas", + "teamCollaboration": "Trabalhar juntos perfeitamente", + "resourcePlanning": "Gerenciar tempo e recursos", + "clientCommunication": "Manter-se conectado com clientes", + "timeTracking": "Monitorar horas do projeto", + "other": "Algo mais" + } } diff --git a/worklenz-frontend/public/locales/pt/admin-center/current-bill.json b/worklenz-frontend/public/locales/pt/admin-center/current-bill.json index 2e4b41d74..c5b99acbb 100644 --- a/worklenz-frontend/public/locales/pt/admin-center/current-bill.json +++ b/worklenz-frontend/public/locales/pt/admin-center/current-bill.json @@ -101,6 +101,7 @@ "expirestoday": "hoje", "expirestomorrow": "amanhã", + "expiredDayAgo": "há {{days}} dia", "expiredDaysAgo": "há {{days}} dias", "creditPlan": "Plano de Crédito", "customPlan": "Plano Personalizado", diff --git a/worklenz-frontend/public/locales/pt/common.json b/worklenz-frontend/public/locales/pt/common.json index ce540a28d..d98fdaab7 100644 --- a/worklenz-frontend/public/locales/pt/common.json +++ b/worklenz-frontend/public/locales/pt/common.json @@ -5,5 +5,40 @@ "signup-failed": "Falha no cadastro. Por favor, certifique-se de que todos os campos obrigatórios estão preenchidos e tente novamente.", "reconnecting": "Reconectando ao servidor...", "connection-lost": "Conexão perdida. Tentando reconectar...", - "connection-restored": "Conexão restaurada. Reconectando ao servidor..." + "connection-restored": "Conexão restaurada. Reconectando ao servidor...", + "cancel": "Cancelar", + "update-available": "Worklenz atualizado!", + "update-description": "Uma nova versão do Worklenz está disponível com os recursos e melhorias mais recentes.", + "update-instruction": "Para obter a melhor experiência, por favor recarregue a página para aplicar as novas mudanças.", + "update-whats-new": "💡 <1>O que há de novo: Performance aprimorada, correções de bugs e experiência do usuário melhorada", + "update-now": "Atualizar agora", + "update-later": "Mais tarde", + "updating": "Atualizando...", + "license-expired-title": "Período de teste expirado", + "license-expired-subtitle": "Seu período de teste do Worklenz terminou. Por favor, atualize para continuar aproveitando todos os recursos.", + "license-expired-features": "Atualize agora para desbloquear:", + "license-expired-feature-1": "✓ Projetos e tarefas ilimitados", + "license-expired-feature-2": "✓ Relatórios e análises avançadas", + "license-expired-feature-3": "✓ Recursos de colaboração em equipe", + "license-expired-feature-4": "✓ Suporte prioritário", + "license-expired-upgrade": "Atualizar agora", + "license-expired-days-remaining": "{{days}} dias restantes no seu período de teste", + "trial-expiring-soon": "Seu período de teste expira em {{days}} dia", + "trial-expiring-soon_plural": "Seu período de teste expira em {{days}} dias", + "trial-expiring-today": "Seu período de teste expira hoje!", + "trial-expiring-upgrade": "Atualize agora para manter todos os seus dados e continuar sem interrupções", + "trial-badge-days": "{{days}}d restantes", + "trial-badge-today": "Último dia!", + "trial-badge-hours": "{{hours}}h restantes", + "trial-alert-admin-note": "Você ainda pode acessar o Centro de administração para gerenciar sua assinatura", + "trial-alert-dismiss": "Dispensar por hoje", + "license-expiring-soon": "Sua licença expira em {{days}} dia", + "license-expiring-soon_plural": "Sua licença expira em {{days}} dias", + "license-expiring-today": "Sua licença expira hoje!", + "license-expired-grace-period": "Sua licença expirou. {{days}} dia de período de carência restante", + "license-expired-grace-period_plural": "Sua licença expirou. {{days}} dias de período de carência restantes", + "license-expiring-upgrade": "Renove agora para manter todos os seus dados e continuar sem interrupções", + "license-badge-days": "{{days}}d restantes", + "license-badge-today": "Último dia!", + "license-badge-hours": "{{hours}}h restantes" } diff --git a/worklenz-frontend/public/locales/pt/home.json b/worklenz-frontend/public/locales/pt/home.json index b19ece5f0..398f16c72 100644 --- a/worklenz-frontend/public/locales/pt/home.json +++ b/worklenz-frontend/public/locales/pt/home.json @@ -40,6 +40,22 @@ "list": "Lista", "calendar": "Calendário", "tasks": "Tarefas", - "refresh": "Atualizar" + "refresh": "Atualizar", + "recentActivity": "Atividade Recente", + "recentTasks": "Tarefas Recentes", + "recentTasksSegment": "Tarefas Recentes", + "timeLogged": "Tempo Registrado", + "timeLoggedSegment": "Tempo Registrado", + "noRecentTasks": "Nenhuma tarefa recente", + "noTimeLoggedTasks": "Nenhuma tarefa com tempo registrado", + "activityTag": "Atividade", + "timeLogTag": "Registro de Tempo", + "timerTag": "Cronômetro", + "activitySingular": "atividade", + "activityPlural": "atividades", + "recentTaskAriaLabel": "Tarefa recente:", + "timeLoggedTaskAriaLabel": "Tarefa com tempo registrado:", + "errorLoadingRecentTasks": "Erro ao carregar tarefas recentes", + "errorLoadingTimeLoggedTasks": "Erro ao carregar tarefas com tempo registrado" } } diff --git a/worklenz-frontend/public/locales/pt/kanban-board.json b/worklenz-frontend/public/locales/pt/kanban-board.json index a2034daac..5bac3adb0 100644 --- a/worklenz-frontend/public/locales/pt/kanban-board.json +++ b/worklenz-frontend/public/locales/pt/kanban-board.json @@ -10,6 +10,17 @@ "deleteConfirmationOk": "Sim", "deleteConfirmationCancel": "Cancelar", + "deleteTaskTitle": "Excluir Tarefa", + "deleteTaskContent": "Tem certeza de que deseja excluir esta tarefa? Esta ação não pode ser desfeita.", + "deleteTaskConfirm": "Excluir", + "deleteTaskCancel": "Cancelar", + + "deleteStatusTitle": "Excluir Status", + "deleteStatusContent": "Tem certeza de que deseja excluir este status? Esta ação não pode ser desfeita.", + + "deletePhaseTitle": "Excluir Fase", + "deletePhaseContent": "Tem certeza de que deseja excluir esta fase? Esta ação não pode ser desfeita.", + "dueDate": "Data de vencimento", "cancel": "Cancelar", @@ -26,5 +37,17 @@ "noDueDate": "Sem data de vencimento", "save": "Salvar", "clear": "Limpar", - "nextWeek": "Próxima semana" + "nextWeek": "Próxima semana", + "noSubtasks": "Sem subtarefas", + "showSubtasks": "Mostrar subtarefas", + "hideSubtasks": "Ocultar subtarefas", + + "errorLoadingTasks": "Erro ao carregar tarefas", + "noTasksFound": "Nenhuma tarefa encontrada", + "loadingFilters": "Carregando filtros...", + "failedToUpdateColumnOrder": "Falha ao atualizar a ordem das colunas", + "failedToUpdatePhaseOrder": "Falha ao atualizar a ordem das fases", + "pleaseTryAgain": "Por favor, tente novamente", + "taskNotCompleted": "Tarefa não está concluída", + "completeTaskDependencies": "Por favor, complete as dependências da tarefa antes de prosseguir" } diff --git a/worklenz-frontend/public/locales/pt/navbar.json b/worklenz-frontend/public/locales/pt/navbar.json index be0f3a630..f462a4fc1 100644 --- a/worklenz-frontend/public/locales/pt/navbar.json +++ b/worklenz-frontend/public/locales/pt/navbar.json @@ -18,6 +18,7 @@ "profileTooltip": "Ver perfil", "adminCenter": "Centro de administração", "settings": "Configurações", + "deleteAccount": "Excluir Conta", "logOut": "Sair", "notificationsDrawer": { "read": "Notificações lidas", diff --git a/worklenz-frontend/public/locales/pt/project-view-members.json b/worklenz-frontend/public/locales/pt/project-view-members.json index 725248071..df6eded07 100644 --- a/worklenz-frontend/public/locales/pt/project-view-members.json +++ b/worklenz-frontend/public/locales/pt/project-view-members.json @@ -13,5 +13,6 @@ "deleteButtonTooltip": "Remover do projeto", "memberCount": "Membro", "membersCountPlural": "Membros", - "emptyText": "Não há anexos no projeto." + "emptyText": "Não há anexos no projeto.", + "searchPlaceholder": "Pesquisar membros" } diff --git a/worklenz-frontend/public/locales/pt/project-view/project-member-drawer.json b/worklenz-frontend/public/locales/pt/project-view/project-member-drawer.json index 0afe3d87d..0c5c7b1a8 100644 --- a/worklenz-frontend/public/locales/pt/project-view/project-member-drawer.json +++ b/worklenz-frontend/public/locales/pt/project-view/project-member-drawer.json @@ -3,5 +3,9 @@ "searchLabel": "Adicionar membros inserindo nome ou e-mail", "searchPlaceholder": "Digite nome ou e-mail", "inviteAsAMember": "Convidar como membro", - "inviteNewMemberByEmail": "Convidar novo membro por e-mail" + "inviteNewMemberByEmail": "Convidar novo membro por e-mail", + "members": "Membros", + "copyProjectLink": "Copiar link do projeto", + "inviteMember": "Convidar membro", + "alsoInviteToProject": "Convidar também para o projeto" } diff --git a/worklenz-frontend/public/locales/pt/settings/labels.json b/worklenz-frontend/public/locales/pt/settings/labels.json index 737dccef6..20c5dc6b7 100644 --- a/worklenz-frontend/public/locales/pt/settings/labels.json +++ b/worklenz-frontend/public/locales/pt/settings/labels.json @@ -7,5 +7,9 @@ "searchPlaceholder": "Pesquisar por nome", "emptyText": "Os rótulos podem ser criados ao atualizar ou criar tarefas.", "pinTooltip": "Clique para fixar isso no menu principal", - "colorChangeTooltip": "Clique para mudar a cor" + "colorChangeTooltip": "Clique para mudar a cor", + "pageTitle": "Gerenciar Rótulos", + "deleteConfirmTitle": "Tem certeza de que deseja excluir isto?", + "deleteButton": "Excluir", + "cancelButton": "Cancelar" } diff --git a/worklenz-frontend/public/locales/pt/settings/team-members.json b/worklenz-frontend/public/locales/pt/settings/team-members.json index 9ace17644..9bb38de39 100644 --- a/worklenz-frontend/public/locales/pt/settings/team-members.json +++ b/worklenz-frontend/public/locales/pt/settings/team-members.json @@ -28,7 +28,7 @@ "jobTitleLabel": "Título do Emprego", "jobTitlePlaceholder": "Selecione ou pesquise o título do emprego (Opcional)", "memberAccessLabel": "Nível de Acesso", - "addToTeamButton": "Adicionar Membro à Equipe", + "addToTeamButton": "Enviar convite", "updateButton": "Salvar Alterações", "resendInvitationButton": "Redirecionar Email de Convite", "invitationSentSuccessMessage": "Convite para a equipe enviado com sucesso!", @@ -43,5 +43,6 @@ "updatedText": "Atualizado", "noResultFound": "Digite um endereço de email e pressione enter...", "jobTitlesFetchError": "Falha ao buscar cargos", - "invitationResent": "Convite reenviado com sucesso!" + "invitationResent": "Convite reenviado com sucesso!", + "copyTeamLink": "Copiar link da equipe" } diff --git a/worklenz-frontend/public/locales/pt/survey.json b/worklenz-frontend/public/locales/pt/survey.json new file mode 100644 index 000000000..250eaf900 --- /dev/null +++ b/worklenz-frontend/public/locales/pt/survey.json @@ -0,0 +1,14 @@ +{ + "modalTitle": "Ajude-nos a melhorar sua experiência", + "skip": "Pular por enquanto", + "previous": "Anterior", + "next": "Próximo", + "completeSurvey": "Concluir Pesquisa", + "submitting": "Enviando suas respostas...", + "submitSuccessTitle": "Obrigado!", + "submitSuccessSubtitle": "Seu feedback nos ajuda a melhorar o Worklenz para todos.", + "submitSuccessMessage": "Obrigado por completar a pesquisa!", + "submitErrorMessage": "Falha ao enviar a pesquisa. Por favor, tente novamente.", + "submitErrorLog": "Falha ao enviar a pesquisa", + "fetchErrorLog": "Falha ao buscar a pesquisa" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/task-list-filters.json b/worklenz-frontend/public/locales/pt/task-list-filters.json index 3674a29a1..a6abcf224 100644 --- a/worklenz-frontend/public/locales/pt/task-list-filters.json +++ b/worklenz-frontend/public/locales/pt/task-list-filters.json @@ -84,5 +84,12 @@ "close": "Fechar", "cannotMoveStatus": "Não é possível mover o status", "cannotMoveStatusMessage": "Não é possível mover este status porque deixaria a categoria '{{categoryName}}' vazia. Cada categoria deve ter pelo menos um status.", - "ok": "OK" + "ok": "OK", + "clearSort": "Limpar Ordenação", + "sortAscending": "Ordenar Crescente", + "sortDescending": "Ordenar Decrescente", + "sortByField": "Ordenar por {{field}}", + "ascendingOrder": "Crescente", + "descendingOrder": "Decrescente", + "currentSort": "Ordenação atual: {{field}} {{order}}" } diff --git a/worklenz-frontend/public/locales/pt/task-list-table.json b/worklenz-frontend/public/locales/pt/task-list-table.json index 54fd2a333..d519ec8f7 100644 --- a/worklenz-frontend/public/locales/pt/task-list-table.json +++ b/worklenz-frontend/public/locales/pt/task-list-table.json @@ -57,6 +57,9 @@ "contextMenu": { "assignToMe": "Atribuir a mim", + "copyLink": "Copiar link da tarefa", + "linkCopied": "Link copiado para a área de transferência", + "linkCopyFailed": "Falha ao copiar o link", "moveTo": "Mover para", "unarchive": "Desarquivar", "archive": "Arquivar", @@ -133,5 +136,11 @@ "dependencies": "A tarefa tem dependências", "recurring": "Tarefa recorrente" } + }, + + "timer": { + "conflictTitle": "Temporizador Já Em Execução", + "conflictMessage": "Você tem um temporizador executando para \"{{taskName}}\" no projeto \"{{projectName}}\". Gostaria de parar esse temporizador e iniciar um novo para esta tarefa?", + "stopAndStart": "Parar e Iniciar Novo Temporizador" } } diff --git a/worklenz-frontend/public/locales/zh/account-setup.json b/worklenz-frontend/public/locales/zh/account-setup.json index 51cac1eb6..b9a0e46ed 100644 --- a/worklenz-frontend/public/locales/zh/account-setup.json +++ b/worklenz-frontend/public/locales/zh/account-setup.json @@ -1,14 +1,38 @@ { "continue": "继续", - "setupYourAccount": "设置您的Worklenz账户。", + "setupYourAccount": "设置您的 Worklenz 账户。", "organizationStepTitle": "命名您的组织", - "organizationStepLabel": "为您的Worklenz账户选择一个名称。", + "organizationStepWelcome": "欢迎使用 Worklenz!", + "organizationStepDescription": "让我们从设置您的组织开始。这将是您团队的主要工作空间。", + "organizationStepLabel": "组织名称", + "organizationStepPlaceholder": "例如:Acme 公司", + "organizationStepTooltip": "此名称将显示在您的工作区,并可在设置中更改。", + "organizationStepNeedIdeas": "需要灵感?", + "organizationStepUseDetected": "检测到使用:", + "organizationStepCharacters": "字符", + "organizationStepGoodLength": "长度合适", + "organizationStepTooShort": "太短", + "organizationStepNamingTips": "命名建议", + "organizationStepTip1": "保持简单且易记", + "organizationStepTip2": "体现您的行业或价值观", + "organizationStepTip3": "考虑未来发展", + "organizationStepTip4": "使其独特且有品牌感", + "organizationStepSuggestionsTitle": "名称建议", + "organizationStepCategory1": "科技公司", + "organizationStepCategory2": "创意机构", + "organizationStepCategory3": "咨询公司", + "organizationStepCategory4": "初创企业", + "organizationStepSuggestionsNote": "这些只是帮助您入门的示例。请选择能代表您组织的名称。", + "organizationStepPrivacyNote": "您的组织名称是私有的,仅团队成员可见。", + "projectStepTitle": "创建您的第一个项目", "projectStepLabel": "您现在正在做什么项目?", "projectStepPlaceholder": "例如:营销计划", + "tasksStepTitle": "创建您的第一个任务", "tasksStepLabel": "输入您将在其中完成的几个任务", "tasksStepAddAnother": "添加另一个", + "emailPlaceholder": "电子邮件地址", "invalidEmail": "请输入有效的电子邮件地址", "or": "或", @@ -20,8 +44,170 @@ "step3InputLabel": "通过电子邮件邀请", "addAnother": "添加另一个", "skipForNow": "暂时跳过", + "skipping": "跳过中...", "formTitle": "创建您的第一个任务。", "step3Title": "邀请您的团队一起工作", - "maxMembers": "(您最多可以邀请5名成员)", - "maxTasks": "(您最多可以创建5个任务)" -} \ No newline at end of file + "maxMembers": "(您最多可以邀请 5 名成员)", + "maxTasks": "(您最多可以创建 5 个任务)", + + "membersStepTitle": "邀请您的团队", + "membersStepDescription": "将团队成员添加到 \"{{organizationName}}\" 并开始协作", + "memberPlaceholder": "团队成员 {{index}} - 输入电子邮件地址", + "validEmailAddress": "有效的电子邮件地址", + "addAnotherTeamMember": "添加另一个团队成员 ({{current}}/{{max}})", + "canInviteLater": "您可以稍后邀请团队成员", + "skipStepDescription": "没有准备好电子邮件地址?没关系!您可以跳过此步骤,稍后从项目面板邀请团队成员。", + + "orgCategoryTech": "科技公司", + "orgCategoryCreative": "创意机构", + "orgCategoryConsulting": "咨询公司", + "orgCategoryStartups": "初创企业", + "namingTip1": "保持简单且易记", + "namingTip2": "体现您的行业或价值观", + "namingTip3": "考虑未来发展", + "namingTip4": "使其独特且有品牌感", + + "aboutYouTitle": "告诉我们关于您的信息", + "aboutYouDescription": "帮助我们个性化您的体验", + "orgTypeQuestion": "哪项最能描述您的组织?", + "userRoleQuestion": "您的角色是什么?", + + "yourNeedsTitle": "您的主要需求是什么?", + "yourNeedsDescription": "请选择所有适用项,帮助我们设置您的工作区", + "yourNeedsQuestion": "您主要如何使用 Worklenz?", + "useCaseTaskOrg": "组织和跟踪任务", + "useCaseTeamCollab": "团队协作", + "useCaseResourceMgmt": "管理时间和资源", + "useCaseClientComm": "与客户保持联系", + "useCaseTimeTrack": "监控项目工时", + "useCaseOther": "其他", + "selectedText": "已选择", + "previousToolsQuestion": "您之前用过哪些工具?(可选)", + "previousToolsPlaceholder": "例如:Asana、Trello、Jira、Monday.com 等", + + "discoveryTitle": "最后一个问题……", + "discoveryDescription": "帮助我们了解您是如何发现 Worklenz 的", + "discoveryQuestion": "您是如何听说我们的?", + "allSetTitle": "一切就绪!", + "allSetDescription": "让我们创建您的第一个项目并开始使用 Worklenz 吧", + "surveyCompleteTitle": "谢谢!", + "surveyCompleteDescription": "您的反馈有助于我们为所有人改进 Worklenz", + "aboutYouStepName": "关于您", + "yourNeedsStepName": "您的需求", + "discoveryStepName": "发现", + "stepProgress": "第 {step} 步,共 3 步:{title}", + + "projectStepHeader": "让我们创建您的第一个项目", + "projectStepSubheader": "从头开始或使用模板更快上手", + "startFromScratch": "从头开始", + "templateSelected": "已选择模板如下", + "quickSuggestions": "快速建议:", + "orText": "或", + "startWithTemplate": "从模板开始", + "clearToSelectTemplate": "请先清空上方项目名称以选择模板", + "templateHeadStart": "使用预设项目结构快速开始", + "browseAllTemplates": "浏览所有模板", + "templatesAvailable": "15+ 行业专用模板可用", + "chooseTemplate": "选择与您的项目类型匹配的模板", + "createProject": "创建项目", + + "templateSoftwareDev": "软件开发", + "templateSoftwareDesc": "敏捷冲刺、缺陷跟踪、版本发布", + "templateMarketing": "市场营销活动", + "templateMarketingDesc": "活动策划、内容日历", + "templateConstruction": "建设项目", + "templateConstructionDesc": "阶段、许可、承包商", + "templateStartup": "初创启动", + "templateStartupDesc": "MVP 开发、融资、增长", + + "tasksStepTitle": "添加您的第一个任务", + "tasksStepDescription": "将 \"{{projectName}}\" 拆分为可执行任务以开始", + "taskPlaceholder": "任务 {{index}} - 例如:需要做什么?", + "addAnotherTask": "添加另一个任务 ({{current}}/{{max}})", + + "surveyStepTitle": "告诉我们关于您的信息", + "surveyStepLabel": "通过回答几个问题帮助我们个性化您的 Worklenz 体验。", + + "organizationType": "哪项最能描述您的组织?", + "organizationTypeFreelancer": "自由职业者", + "organizationTypeStartup": "初创公司", + "organizationTypeSmallMediumBusiness": "中小企业", + "organizationTypeAgency": "代理机构", + "organizationTypeEnterprise": "企业", + "organizationTypeOther": "其他", + + "userRole": "您的角色是什么?", + "userRoleFounderCeo": "创始人 / CEO", + "userRoleProjectManager": "项目经理", + "userRoleSoftwareDeveloper": "软件开发者", + "userRoleDesigner": "设计师", + "userRoleOperations": "运营", + "userRoleOther": "其他", + + "mainUseCases": "您主要将 Worklenz 用于什么?", + "mainUseCasesTaskManagement": "任务管理", + "mainUseCasesTeamCollaboration": "团队协作", + "mainUseCasesResourcePlanning": "资源规划", + "mainUseCasesClientCommunication": "客户沟通与报告", + "mainUseCasesTimeTracking": "时间跟踪", + "mainUseCasesOther": "其他", + + "previousTools": "在使用 Worklenz 之前您用过哪些工具?", + "previousToolsPlaceholder": "例如:Trello、Asana、Monday.com", + + "howHeardAbout": "您是如何了解 Worklenz 的?", + "howHeardAboutGoogleSearch": "Google 搜索", + "howHeardAboutTwitter": "Twitter", + "howHeardAboutLinkedin": "LinkedIn", + "howHeardAboutFriendColleague": "朋友或同事", + "howHeardAboutBlogArticle": "博客或文章", + "howHeardAboutOther": "其他", + + "aboutYouStepTitle": "告诉我们关于您的信息", + "aboutYouStepDescription": "帮助我们个性化您的体验", + "yourNeedsStepTitle": "您的主要需求是什么?", + "yourNeedsStepDescription": "选择所有适用的选项,帮助我们设置您的工作空间", + "selected": "已选择", + "previousToolsLabel": "您之前使用过哪些工具?(可选)", + + "roleSuggestions": { + "designer": "UI/UX、图形、创意", + "developer": "前端、后端、全栈", + "projectManager": "规划、协调", + "marketing": "内容、社交媒体、增长", + "sales": "业务发展、客户关系", + "operations": "行政、人力资源、财务" + }, + + "languages": { + "en": "English", + "es": "Español", + "pt": "Português", + "de": "Deutsch", + "alb": "Shqip", + "zh": "简体中文" + }, + + "orgSuggestions": { + "tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"], + "creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"], + "consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"], + "startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"] + }, + + "projectSuggestions": { + "freelancer": ["客户项目", "作品集更新", "个人品牌"], + "startup": ["MVP开发", "产品发布", "市场调研"], + "agency": ["客户活动", "品牌策略", "网站重设计"], + "enterprise": ["系统迁移", "流程优化", "团队培训"] + }, + + "useCaseDescriptions": { + "taskManagement": "组织和跟踪任务", + "teamCollaboration": "无缝协作", + "resourcePlanning": "管理时间和资源", + "clientCommunication": "与客户保持联系", + "timeTracking": "监控项目时间", + "other": "其他" + } +} diff --git a/worklenz-frontend/public/locales/zh/admin-center/current-bill.json b/worklenz-frontend/public/locales/zh/admin-center/current-bill.json index e18e8761b..e5333ff71 100644 --- a/worklenz-frontend/public/locales/zh/admin-center/current-bill.json +++ b/worklenz-frontend/public/locales/zh/admin-center/current-bill.json @@ -90,6 +90,7 @@ "switchToFreePlan": "切换到免费计划", "expirestoday": "今天", "expirestomorrow": "明天", + "expiredDayAgo": "{{days}}天前", "expiredDaysAgo": "{{days}}天前", "continueWith": "继续使用{{plan}}", "changeToPlan": "更改为{{plan}}" diff --git a/worklenz-frontend/public/locales/zh/common.json b/worklenz-frontend/public/locales/zh/common.json index 520ee5e21..7fa13278b 100644 --- a/worklenz-frontend/public/locales/zh/common.json +++ b/worklenz-frontend/public/locales/zh/common.json @@ -5,5 +5,40 @@ "signup-failed": "注册失败。请确保填写所有必填字段并重试。", "reconnecting": "与服务器断开连接。", "connection-lost": "无法连接到服务器。请检查您的互联网连接。", - "connection-restored": "成功连接到服务器" + "connection-restored": "成功连接到服务器", + "cancel": "取消", + "update-available": "Worklenz 已更新!", + "update-description": "Worklenz 的新版本已可用,具有最新的功能和改进。", + "update-instruction": "为了获得最佳体验,请刷新页面以应用新更改。", + "update-whats-new": "💡 <1>新增内容:性能增强、错误修复和用户体验改善", + "update-now": "立即更新", + "update-later": "稍后", + "updating": "正在更新...", + "license-expired-title": "试用期已过", + "license-expired-subtitle": "您的 Worklenz 试用期已结束。请升级以继续享受所有功能。", + "license-expired-features": "立即升级解锁:", + "license-expired-feature-1": "✓ 无限项目和任务", + "license-expired-feature-2": "✓ 高级报告和分析", + "license-expired-feature-3": "✓ 团队协作功能", + "license-expired-feature-4": "✓ 优先支持", + "license-expired-upgrade": "立即升级", + "license-expired-days-remaining": "试用期剩余 {{days}} 天", + "trial-expiring-soon": "您的试用期还有 {{days}} 天到期", + "trial-expiring-soon_plural": "您的试用期还有 {{days}} 天到期", + "trial-expiring-today": "您的试用期今天到期!", + "trial-expiring-upgrade": "立即升级以保留所有数据并继续使用", + "trial-badge-days": "剩余{{days}}天", + "trial-badge-today": "最后一天!", + "trial-badge-hours": "剩余{{hours}}小时", + "trial-alert-admin-note": "您仍可以访问管理中心管理您的订阅", + "trial-alert-dismiss": "今日暂不提醒", + "license-expiring-soon": "您的许可证还有 {{days}} 天到期", + "license-expiring-soon_plural": "您的许可证还有 {{days}} 天到期", + "license-expiring-today": "您的许可证今天到期!", + "license-expired-grace-period": "您的许可证已过期。剩余 {{days}} 天宽限期", + "license-expired-grace-period_plural": "您的许可证已过期。剩余 {{days}} 天宽限期", + "license-expiring-upgrade": "立即续费以保留所有数据并继续使用", + "license-badge-days": "剩余{{days}}天", + "license-badge-today": "最后一天!", + "license-badge-hours": "剩余{{hours}}小时" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/home.json b/worklenz-frontend/public/locales/zh/home.json index 184b4f1ab..638e14f8c 100644 --- a/worklenz-frontend/public/locales/zh/home.json +++ b/worklenz-frontend/public/locales/zh/home.json @@ -41,6 +41,22 @@ "list": "列表", "calendar": "日历", "tasks": "任务", - "refresh": "刷新" + "refresh": "刷新", + "recentActivity": "最近活动", + "recentTasks": "最近任务", + "recentTasksSegment": "最近任务", + "timeLogged": "时间记录", + "timeLoggedSegment": "时间记录", + "noRecentTasks": "没有最近任务", + "noTimeLoggedTasks": "没有时间记录任务", + "activityTag": "活动", + "timeLogTag": "时间记录", + "timerTag": "计时器", + "activitySingular": "活动", + "activityPlural": "活动", + "recentTaskAriaLabel": "最近任务:", + "timeLoggedTaskAriaLabel": "时间记录任务:", + "errorLoadingRecentTasks": "加载最近任务时出错", + "errorLoadingTimeLoggedTasks": "加载时间记录任务时出错" } } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/kanban-board.json b/worklenz-frontend/public/locales/zh/kanban-board.json index 7b72c5d50..20c7cb087 100644 --- a/worklenz-frontend/public/locales/zh/kanban-board.json +++ b/worklenz-frontend/public/locales/zh/kanban-board.json @@ -15,5 +15,32 @@ "assignToMe": "分配给我", "archive": "归档", "newTaskNamePlaceholder": "写一个任务名称", - "newSubtaskNamePlaceholder": "写一个子任务名称" + "newSubtaskNamePlaceholder": "写一个子任务名称", + "deleteTaskTitle": "删除任务", + "deleteTaskContent": "您确定要删除此任务吗?此操作无法撤销。", + "deleteTaskConfirm": "删除", + "deleteTaskCancel": "取消", + "deleteStatusTitle": "删除状态", + "deleteStatusContent": "您确定要删除此状态吗?此操作无法撤销。", + "deletePhaseTitle": "删除阶段", + "deletePhaseContent": "您确定要删除此阶段吗?此操作无法撤销。", + "untitledSection": "未命名部分", + "unmapped": "未映射", + "clickToChangeDate": "点击更改日期", + "noDueDate": "无截止日期", + "save": "保存", + "clear": "清除", + "nextWeek": "下周", + "noSubtasks": "无子任务", + "showSubtasks": "显示子任务", + "hideSubtasks": "隐藏子任务", + + "errorLoadingTasks": "加载任务时出错", + "noTasksFound": "未找到任务", + "loadingFilters": "正在加载过滤器...", + "failedToUpdateColumnOrder": "更新列顺序失败", + "failedToUpdatePhaseOrder": "更新阶段顺序失败", + "pleaseTryAgain": "请重试", + "taskNotCompleted": "任务未完成", + "completeTaskDependencies": "请先完成任务依赖项,然后再继续" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/navbar.json b/worklenz-frontend/public/locales/zh/navbar.json index c4ed67abb..02e1abfc3 100644 --- a/worklenz-frontend/public/locales/zh/navbar.json +++ b/worklenz-frontend/public/locales/zh/navbar.json @@ -18,6 +18,7 @@ "profileTooltip": "查看个人资料", "adminCenter": "管理中心", "settings": "设置", + "deleteAccount": "删除账户", "logOut": "登出", "notificationsDrawer": { "read": "已读通知", diff --git a/worklenz-frontend/public/locales/zh/project-view/project-member-drawer.json b/worklenz-frontend/public/locales/zh/project-view/project-member-drawer.json index f412f22bf..512ab0d0e 100644 --- a/worklenz-frontend/public/locales/zh/project-view/project-member-drawer.json +++ b/worklenz-frontend/public/locales/zh/project-view/project-member-drawer.json @@ -3,5 +3,9 @@ "searchLabel": "通过添加名称或电子邮件添加成员", "searchPlaceholder": "输入名称或电子邮件", "inviteAsAMember": "邀请为成员", - "inviteNewMemberByEmail": "通过电子邮件邀请新成员" + "inviteNewMemberByEmail": "通过电子邮件邀请新成员", + "members": "成员", + "copyProjectLink": "复制项目链接", + "inviteMember": "邀请成员", + "alsoInviteToProject": "也邀请到项目" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/settings/labels.json b/worklenz-frontend/public/locales/zh/settings/labels.json index ab0d01cd7..af3310b7b 100644 --- a/worklenz-frontend/public/locales/zh/settings/labels.json +++ b/worklenz-frontend/public/locales/zh/settings/labels.json @@ -7,5 +7,9 @@ "searchPlaceholder": "按名称搜索", "emptyText": "标签可以在更新或创建任务时创建。", "pinTooltip": "点击将其固定到主菜单", - "colorChangeTooltip": "点击更改颜色" + "colorChangeTooltip": "点击更改颜色", + "pageTitle": "管理标签", + "deleteConfirmTitle": "您确定要删除这个吗?", + "deleteButton": "删除", + "cancelButton": "取消" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/settings/team-members.json b/worklenz-frontend/public/locales/zh/settings/team-members.json index 8b39483c4..8e9bcfb05 100644 --- a/worklenz-frontend/public/locales/zh/settings/team-members.json +++ b/worklenz-frontend/public/locales/zh/settings/team-members.json @@ -28,7 +28,7 @@ "jobTitleLabel": "职位", "jobTitlePlaceholder": "选择或搜索职位(可选)", "memberAccessLabel": "访问级别", - "addToTeamButton": "将成员添加到团队", + "addToTeamButton": "发送邀请", "updateButton": "保存更改", "resendInvitationButton": "重新发送邀请邮件", "invitationSentSuccessMessage": "团队邀请已成功发送!", @@ -43,5 +43,6 @@ "updatedText": "已更新", "noResultFound": "输入电子邮件地址并按回车键...", "jobTitlesFetchError": "获取职位失败", - "invitationResent": "邀请重新发送成功!" + "invitationResent": "邀请重新发送成功!", + "copyTeamLink": "复制团队链接" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/survey.json b/worklenz-frontend/public/locales/zh/survey.json new file mode 100644 index 000000000..b472db9c6 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/survey.json @@ -0,0 +1,14 @@ +{ + "modalTitle": "帮助我们提升您的体验", + "skip": "暂时跳过", + "previous": "上一步", + "next": "下一步", + "completeSurvey": "完成调查", + "submitting": "正在提交您的回答...", + "submitSuccessTitle": "谢谢!", + "submitSuccessSubtitle": "您的反馈帮助我们改进 Worklenz。", + "submitSuccessMessage": "感谢您完成调查!", + "submitErrorMessage": "提交调查失败。请重试。", + "submitErrorLog": "提交调查失败", + "fetchErrorLog": "获取调查失败" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/task-list-filters.json b/worklenz-frontend/public/locales/zh/task-list-filters.json index 4d1d6b432..f5617aac7 100644 --- a/worklenz-frontend/public/locales/zh/task-list-filters.json +++ b/worklenz-frontend/public/locales/zh/task-list-filters.json @@ -79,5 +79,12 @@ "close": "关闭", "cannotMoveStatus": "无法移动状态", "cannotMoveStatusMessage": "无法移动此状态,因为这会使\"{{categoryName}}\"类别为空。每个类别必须至少有一个状态。", - "ok": "确定" + "ok": "确定", + "clearSort": "清除排序", + "sortAscending": "升序排列", + "sortDescending": "降序排列", + "sortByField": "按{{field}}排序", + "ascendingOrder": "升序", + "descendingOrder": "降序", + "currentSort": "当前排序:{{field}} {{order}}" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/task-list-table.json b/worklenz-frontend/public/locales/zh/task-list-table.json index 63718830d..e61fe4039 100644 --- a/worklenz-frontend/public/locales/zh/task-list-table.json +++ b/worklenz-frontend/public/locales/zh/task-list-table.json @@ -50,6 +50,9 @@ "pendingInvitation": "待处理邀请", "contextMenu": { "assignToMe": "分配给我", + "copyLink": "复制任务链接", + "linkCopied": "链接已复制到剪贴板", + "linkCopyFailed": "复制链接失败", "moveTo": "移动到", "unarchive": "取消归档", "archive": "归档", @@ -126,5 +129,11 @@ "dependencies": "任务有依赖项", "recurring": "重复任务" } + }, + + "timer": { + "conflictTitle": "计时器已在运行", + "conflictMessage": "您在项目\"{{projectName}}\"中的\"{{taskName}}\"任务正在运行计时器。您是否要停止该计时器并为此任务启动新的计时器?", + "stopAndStart": "停止并启动新计时器" } } \ No newline at end of file diff --git a/worklenz-frontend/public/sw.js b/worklenz-frontend/public/sw.js index 15dbef76d..2e299274b 100644 --- a/worklenz-frontend/public/sw.js +++ b/worklenz-frontend/public/sw.js @@ -325,6 +325,12 @@ self.addEventListener('message', event => { event.ports[0].postMessage({ version: CACHE_VERSION }); break; + case 'CHECK_FOR_UPDATES': + checkForUpdates().then((hasUpdates) => { + event.ports[0].postMessage({ hasUpdates }); + }); + break; + case 'CLEAR_CACHE': clearAllCaches().then(() => { event.ports[0].postMessage({ success: true }); @@ -349,6 +355,44 @@ async function clearAllCaches() { console.log('Service Worker: All caches cleared'); } +async function checkForUpdates() { + try { + // Check if there's a new service worker available + const registration = await self.registration.update(); + const hasNewWorker = registration.installing || registration.waiting; + + if (hasNewWorker) { + console.log('Service Worker: New version detected'); + return true; + } + + // Also check if the main app files have been updated by trying to fetch index.html + // and comparing it with the cached version + try { + const cache = await caches.open(CACHE_NAMES.STATIC); + const cachedResponse = await cache.match('/'); + const networkResponse = await fetch('/', { cache: 'no-cache' }); + + if (cachedResponse && networkResponse.ok) { + const cachedContent = await cachedResponse.text(); + const networkContent = await networkResponse.text(); + + if (cachedContent !== networkContent) { + console.log('Service Worker: App content has changed'); + return true; + } + } + } catch (error) { + console.log('Service Worker: Could not check for content updates', error); + } + + return false; + } catch (error) { + console.error('Service Worker: Error checking for updates', error); + return false; + } +} + async function handleLogout() { try { // Clear all caches diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 0f29cdcd3..92beecd6a 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 ModuleErrorBoundary from './components/ModuleErrorBoundary'; +import { UpdateNotificationProvider } from './components/update-notification'; // Routes import router from './app/routes'; @@ -202,14 +203,16 @@ const App: React.FC = memo(() => { return ( }> - - - + + + + + ); diff --git a/worklenz-frontend/src/api/account/account.api.service.ts b/worklenz-frontend/src/api/account/account.api.service.ts new file mode 100644 index 000000000..ed30387df --- /dev/null +++ b/worklenz-frontend/src/api/account/account.api.service.ts @@ -0,0 +1,23 @@ +import apiClient from '../api-client'; +import { API_BASE_URL } from '@/shared/constants'; +import { IServerResponse } from '@/types/common.types'; + +export interface AccountDeletionRequest { + userId: string; + userEmail: string; + userName: string; +} + +const rootUrl = `${API_BASE_URL}/account`; + +export const accountApiService = { + requestDeletion: async (request: AccountDeletionRequest): Promise> => { + const response = await apiClient.post>(`${rootUrl}/deletion-request`, request); + return response.data; + }, + + cancelDeletion: async (): Promise> => { + const response = await apiClient.post>(`${rootUrl}/cancel-deletion`); + return response.data; + }, +}; \ No newline at end of file diff --git a/worklenz-frontend/src/api/home-page/user-activity.api.service.ts b/worklenz-frontend/src/api/home-page/user-activity.api.service.ts new file mode 100644 index 000000000..f37da22a9 --- /dev/null +++ b/worklenz-frontend/src/api/home-page/user-activity.api.service.ts @@ -0,0 +1,46 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { API_BASE_URL } from '@/shared/constants'; +import { getCsrfToken } from '../api-client'; +import { IUserRecentTask, IUserTimeLoggedTask } from '@/types/home/user-activity.types'; +import config from '@/config/env'; + +const rootUrl = '/logs'; + +export const userActivityApiService = createApi({ + reducerPath: 'userActivityApi', + baseQuery: fetchBaseQuery({ + baseUrl: `${config.apiUrl}${API_BASE_URL}`, + prepareHeaders: (headers) => { + headers.set('X-CSRF-Token', getCsrfToken() || ''); + headers.set('Content-Type', 'application/json'); + return headers; + }, + credentials: 'include', + }), + tagTypes: ['UserRecentTasks', 'UserTimeLoggedTasks'], + endpoints: (builder) => ({ + getUserRecentTasks: builder.query({ + query: ({ limit = 10, offset = 0 }) => ({ + url: `${rootUrl}/user-recent-tasks`, + params: { limit, offset }, + method: 'GET', + }), + providesTags: ['UserRecentTasks'], + }), + getUserTimeLoggedTasks: builder.query({ + query: ({ limit = 10, offset = 0 }) => ({ + url: `${rootUrl}/user-time-logged-tasks`, + params: { limit, offset }, + method: 'GET', + }), + providesTags: ['UserTimeLoggedTasks'], + }), + }), +}); + +export const { + useGetUserRecentTasksQuery, + useGetUserTimeLoggedTasksQuery, +} = userActivityApiService; + + diff --git a/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.updated.ts b/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.updated.ts new file mode 100644 index 000000000..a02b5ca2a --- /dev/null +++ b/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.updated.ts @@ -0,0 +1,92 @@ +import { API_BASE_URL } from '@/shared/constants'; +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'; + +const rootUrl = `${API_BASE_URL}/reporting`; + +// Helper function to get user's timezone +const getUserTimezone = () => { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +}; + +export const reportingTimesheetApiService = { + getTimeSheetData: async ( + body = {}, + archived = false + ): Promise> => { + const q = toQueryString({ archived }); + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/allocation/${q}`, bodyWithTimezone); + return response.data; + }, + + getAllocationProjects: async (body = {}) => { + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/allocation/allocation-projects`, bodyWithTimezone); + return response.data; + }, + + getProjectTimeSheets: async ( + body = {}, + archived = false + ): Promise> => { + const q = toQueryString({ archived }); + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/time-reports/projects/${q}`, bodyWithTimezone); + return response.data; + }, + + getMemberTimeSheets: async ( + body = {}, + archived = false + ): Promise> => { + const q = toQueryString({ archived }); + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, bodyWithTimezone); + return response.data; + }, + + getProjectTimeLogs: async ( + body: ITimeLogBreakdownReq + ): Promise> => { + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/project-timelogs`, bodyWithTimezone); + return response.data; + }, + + getProjectEstimatedVsActual: async ( + body = {}, + archived = false + ): Promise> => { + const q = toQueryString({ archived }); + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/time-reports/estimated-vs-actual${q}`, bodyWithTimezone); + return response.data; + }, +}; \ No newline at end of file diff --git a/worklenz-frontend/src/api/support/support.api.service.ts b/worklenz-frontend/src/api/support/support.api.service.ts new file mode 100644 index 000000000..57c0f4dbc --- /dev/null +++ b/worklenz-frontend/src/api/support/support.api.service.ts @@ -0,0 +1,17 @@ +import apiClient from '../api-client'; +import { API_BASE_URL } from '@/shared/constants'; +import { IServerResponse } from '@/types/common.types'; + +export interface ContactSupportRequest { + subscription_type?: string; + reason?: string; +} + +const rootUrl = `${API_BASE_URL}/support`; + +export const supportApiService = { + contactSupport: async (request: ContactSupportRequest): Promise> => { + const response = await apiClient.post>(`${rootUrl}/contact`, request); + return response.data; + }, +}; \ No newline at end of file diff --git a/worklenz-frontend/src/api/survey/survey.api.service.ts b/worklenz-frontend/src/api/survey/survey.api.service.ts new file mode 100644 index 000000000..a236bf511 --- /dev/null +++ b/worklenz-frontend/src/api/survey/survey.api.service.ts @@ -0,0 +1,27 @@ +import { IServerResponse } from '@/types/common.types'; +import { ISurvey, ISurveySubmissionRequest, ISurveyResponse } from '@/types/account-setup/survey.types'; +import apiClient from '../api-client'; + +const API_BASE_URL = '/api/v1'; + +export const surveyApiService = { + async getAccountSetupSurvey(): Promise> { + const response = await apiClient.get>(`${API_BASE_URL}/surveys/account-setup`); + return response.data; + }, + + async submitSurveyResponse(data: ISurveySubmissionRequest): Promise> { + const response = await apiClient.post>(`${API_BASE_URL}/surveys/responses`, data); + return response.data; + }, + + async getUserSurveyResponse(surveyId: string): Promise> { + const response = await apiClient.get>(`${API_BASE_URL}/surveys/responses/${surveyId}`); + return response.data; + }, + + async checkAccountSetupSurveyStatus(): Promise> { + const response = await apiClient.get>(`${API_BASE_URL}/surveys/account-setup/status`); + return response.data; + } +}; \ No newline at end of file diff --git a/worklenz-frontend/src/api/taskAttributes/labels/labels.api.service.ts b/worklenz-frontend/src/api/taskAttributes/labels/labels.api.service.ts index c9e36ff41..fd9a9259d 100644 --- a/worklenz-frontend/src/api/taskAttributes/labels/labels.api.service.ts +++ b/worklenz-frontend/src/api/taskAttributes/labels/labels.api.service.ts @@ -27,12 +27,17 @@ export const labelsApiService = { updateColor: async (labelId: string, color: string): Promise> => { const response = await apiClient.put>( - `${rootUrl}/tasks/${labelId}/color`, + `${rootUrl}/tasks/${labelId}`, { color } ); return response.data; }, + updateLabel: async (labelId: string, data: { name?: string; color?: string }): Promise> => { + const response = await apiClient.put>(`${rootUrl}/team/${labelId}`, data); + return response.data; + }, + deleteById: async (labelId: string): Promise> => { const response = await apiClient.delete>(`${rootUrl}/team/${labelId}`); return response.data; diff --git a/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts b/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts index 1a9191eef..038b85844 100644 --- a/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts +++ b/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts @@ -2,6 +2,7 @@ import { API_BASE_URL } from '@/shared/constants'; import apiClient from '../api-client'; import { IServerResponse } from '@/types/common.types'; import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types'; +import { getUserSession } from '@/utils/session-helper'; const rootUrl = `${API_BASE_URL}/task-time-log`; @@ -17,7 +18,11 @@ export interface IRunningTimer { export const taskTimeLogsApiService = { getByTask: async (id: string): Promise> => { - const response = await apiClient.get(`${rootUrl}/task/${id}`); + const session = getUserSession(); + const timezone = session?.timezone_name || 'UTC'; + const response = await apiClient.get(`${rootUrl}/task/${id}`, { + params: { time_zone_name: timezone } + }); return response.data; }, diff --git a/worklenz-frontend/src/app/routes/index.tsx b/worklenz-frontend/src/app/routes/index.tsx index 722fcd48c..669c58310 100644 --- a/worklenz-frontend/src/app/routes/index.tsx +++ b/worklenz-frontend/src/app/routes/index.tsx @@ -2,7 +2,7 @@ import { createBrowserRouter, Navigate, RouteObject, useLocation } from 'react-r import { lazy, Suspense, memo, useMemo } from 'react'; import rootRoutes from './root-routes'; import authRoutes from './auth-routes'; -import mainRoutes, { licenseExpiredRoute } from './main-routes'; +import mainRoutes from './main-routes'; import notFoundRoute from './not-found-route'; import accountSetupRoute from './account-setup-routes'; import reportingRoutes from './reporting-routes'; @@ -11,6 +11,7 @@ import { AuthenticatedLayout } from '@/layouts/AuthenticatedLayout'; import ErrorBoundary from '@/components/ErrorBoundary'; import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; import { ISUBSCRIPTION_TYPE } from '@/shared/constants'; +import { LicenseExpiredModal } from '@/components/LicenseExpiredModal/LicenseExpiredModal'; // Lazy load the NotFoundPage component for better code splitting const NotFoundPage = lazy(() => import('@/pages/404-page/404-page')); @@ -61,12 +62,34 @@ AdminGuard.displayName = 'AdminGuard'; export const LicenseExpiryGuard = memo(({ children }: GuardProps) => { const { isLicenseExpired, location } = useAuthStatus(); + const authService = useAuthService(); const isAdminCenterRoute = location.pathname.includes('/worklenz/admin-center'); - const isLicenseExpiredRoute = location.pathname === '/worklenz/license-expired'; - - if (isLicenseExpired && !isAdminCenterRoute && !isLicenseExpiredRoute) { - return ; + const isAccountDeletionRoute = location.pathname.includes('/worklenz/settings/account-deletion'); + + // Show modal instead of redirecting, but not on admin center routes or account deletion + const showModal = isLicenseExpired && !isAdminCenterRoute && !isAccountDeletionRoute; + + // Get the user's subscription type + const currentSession = authService?.getCurrentSession(); + const subscriptionType = currentSession?.subscription_type as ISUBSCRIPTION_TYPE; + + // If license is expired and not on admin center, block the content entirely + if (showModal) { + return ( +
+
+ {children} +
+ +
+ ); } return <>{children}; @@ -194,7 +217,7 @@ const adminRoutes = wrapRoutes(reportingRoutes, AdminGuard); // Setup route should be accessible without setup completion, only requires authentication const setupRoutes = wrapRoutes([accountSetupRoute], AuthGuard); -// License expiry check function +// License expiry check function - only wrap top-level routes, not children const withLicenseExpiryCheck = (routes: RouteObject[]): RouteObject[] => { return routes.map(route => { const wrappedRoute = { @@ -206,8 +229,9 @@ const withLicenseExpiryCheck = (routes: RouteObject[]): RouteObject[] => { ), }; + // Don't wrap children - they'll inherit the guard from parent if (route.children) { - wrappedRoute.children = withLicenseExpiryCheck(route.children); + wrappedRoute.children = route.children; } return wrappedRoute; @@ -215,6 +239,7 @@ const withLicenseExpiryCheck = (routes: RouteObject[]): RouteObject[] => { }; const licenseCheckedMainRoutes = withLicenseExpiryCheck(protectedMainRoutes); +const licenseCheckedAdminRoutes = withLicenseExpiryCheck(adminRoutes); // Create optimized router with future flags for better performance const router = createBrowserRouter( @@ -232,7 +257,7 @@ const router = createBrowserRouter( ), - children: [...licenseCheckedMainRoutes, ...adminRoutes, ...setupRoutes, licenseExpiredRoute], + children: [...licenseCheckedMainRoutes, ...licenseCheckedAdminRoutes, ...setupRoutes], }, ...publicRoutes, ], diff --git a/worklenz-frontend/src/app/routes/main-routes.tsx b/worklenz-frontend/src/app/routes/main-routes.tsx index 8ec8cb9a2..32772cd6b 100644 --- a/worklenz-frontend/src/app/routes/main-routes.tsx +++ b/worklenz-frontend/src/app/routes/main-routes.tsx @@ -11,12 +11,10 @@ import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallba const HomePage = lazy(() => import('@/pages/home/home-page')); const ProjectList = lazy(() => import('@/pages/projects/project-list')); const Schedule = lazy(() => import('@/pages/schedule/schedule')); -const ProjectTemplateEditView = lazy( - () => import('@/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView') -); -const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired')); + const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view')); const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized')); +const GanttDemoPage = lazy(() => import('@/pages/GanttDemoPage')); // Define AdminGuard component with defensive programming const AdminGuard = ({ children }: { children: React.ReactNode }) => { @@ -91,18 +89,18 @@ const mainRoutes: RouteObject[] = [ ), }, { - path: `settings/project-templates/edit/:templateId/:templateName`, + path: 'unauthorized', element: ( }> - + ), }, { - path: 'unauthorized', + path: 'gantt-demo', element: ( }> - + ), }, @@ -112,20 +110,4 @@ const mainRoutes: RouteObject[] = [ }, ]; -// License expired route should be separate to avoid being wrapped in LicenseExpiryGuard -export const licenseExpiredRoute: RouteObject = { - path: '/worklenz', - element: , - children: [ - { - path: 'license-expired', - element: ( - }> - - - ), - }, - ], -}; - export default mainRoutes; diff --git a/worklenz-frontend/src/app/store.ts b/worklenz-frontend/src/app/store.ts index 63c738a02..4190f3a9d 100644 --- a/worklenz-frontend/src/app/store.ts +++ b/worklenz-frontend/src/app/store.ts @@ -7,6 +7,7 @@ import userReducer from '@features/user/userSlice'; // Home Page import homePageReducer from '@features/home-page/home-page.slice'; +import userActivityReducer from '@features/home-page/user-activity.slice'; // Account Setup import accountSetupReducer from '@features/account-setup/account-setup.slice'; @@ -82,6 +83,7 @@ import groupingReducer from '@/features/task-management/grouping.slice'; import selectionReducer from '@/features/task-management/selection.slice'; import homePageApiService from '@/api/home-page/home-page.api.service'; import { projectsApi } from '@/api/projects/projects.v1.api.service'; +import { userActivityApiService } from '@/api/home-page/user-activity.api.service'; import projectViewReducer from '@features/project/project-view-slice'; import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice'; @@ -90,7 +92,7 @@ export const store = configureStore({ middleware: getDefaultMiddleware => getDefaultMiddleware({ serializableCheck: false, - }).concat(homePageApiService.middleware, projectsApi.middleware), + }).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware), reducer: { // Auth & User auth: authReducer, @@ -103,6 +105,9 @@ export const store = configureStore({ homePageReducer: homePageReducer, [homePageApiService.reducerPath]: homePageApiService.reducer, [projectsApi.reducerPath]: projectsApi.reducer, + userActivityReducer: userActivityReducer, + [userActivityApiService.reducerPath]: userActivityApiService.reducer, + // Core UI themeReducer: themeReducer, localesReducer: localesReducer, diff --git a/worklenz-frontend/src/components/AssigneeSelector.tsx b/worklenz-frontend/src/components/AssigneeSelector.tsx index 7a489885d..28126441f 100644 --- a/worklenz-frontend/src/components/AssigneeSelector.tsx +++ b/worklenz-frontend/src/components/AssigneeSelector.tsx @@ -11,8 +11,10 @@ import { useAuthService } from '@/hooks/useAuth'; import { Avatar, Button, Checkbox } from '@/components'; import { sortTeamMembers } from '@/utils/sort-team-members'; import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice'; +import { setIsFromAssigner, toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice'; import { updateEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import useIsProjectManager from '@/hooks/useIsProjectManager'; +import { useAuthStatus } from '@/hooks/useAuthStatus'; interface AssigneeSelectorProps { task: IProjectTask; @@ -21,9 +23,9 @@ interface AssigneeSelectorProps { kanbanMode?: boolean; } -const AssigneeSelector: React.FC = ({ - task, - groupId = null, +const AssigneeSelector: React.FC = ({ + task, + groupId = null, isDarkMode = false, kanbanMode = false }) => { @@ -42,6 +44,8 @@ const AssigneeSelector: React.FC = ({ const currentSession = useAuthService().getCurrentSession(); const { socket } = useSocket(); const dispatch = useAppDispatch(); + const { isAdmin } = useAuthStatus(); + const isProjectManager = useIsProjectManager(); const filteredMembers = useMemo(() => { return teamMembers?.data?.filter(member => @@ -64,7 +68,7 @@ const AssigneeSelector: React.FC = ({ useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) && - buttonRef.current && !buttonRef.current.contains(event.target as Node)) { + buttonRef.current && !buttonRef.current.contains(event.target as Node)) { setIsOpen(false); } }; @@ -74,10 +78,10 @@ const AssigneeSelector: React.FC = ({ // Check if the button is still visible in the viewport if (buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect(); - const isVisible = rect.top >= 0 && rect.left >= 0 && - rect.bottom <= window.innerHeight && - rect.right <= window.innerWidth; - + const isVisible = rect.top >= 0 && rect.left >= 0 && + rect.bottom <= window.innerHeight && + rect.right <= window.innerWidth; + if (isVisible) { updateDropdownPosition(); } else { @@ -98,7 +102,7 @@ const AssigneeSelector: React.FC = ({ document.addEventListener('mousedown', handleClickOutside); window.addEventListener('scroll', handleScroll, true); window.addEventListener('resize', handleResize); - + return () => { document.removeEventListener('mousedown', handleClickOutside); window.removeEventListener('scroll', handleScroll, true); @@ -113,10 +117,10 @@ const AssigneeSelector: React.FC = ({ const handleDropdownToggle = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - + if (!isOpen) { updateDropdownPosition(); - + // Prepare team members data when opening const assignees = task?.assignees?.map(assignee => assignee.team_member_id); const membersData = (members?.data || []).map(member => ({ @@ -125,7 +129,7 @@ const AssigneeSelector: React.FC = ({ })); const sortedMembers = sortTeamMembers(membersData); setTeamMembers({ data: sortedMembers }); - + setIsOpen(true); // Focus search input after opening setTimeout(() => { @@ -160,8 +164,8 @@ const AssigneeSelector: React.FC = ({ // Update local team members state for dropdown UI setTeamMembers(prev => ({ ...prev, - data: (prev.data || []).map(member => - member.id === memberId + data: (prev.data || []).map(member => + member.id === memberId ? { ...member, selected: checked } : member ) @@ -198,14 +202,15 @@ const AssigneeSelector: React.FC = ({ const checkMemberSelected = (memberId: string) => { if (!memberId) return false; // Use optimistic assignees if available, otherwise fall back to task assignees - const assignees = optimisticAssignees.length > 0 - ? optimisticAssignees + const assignees = optimisticAssignees.length > 0 + ? optimisticAssignees : task?.assignees?.map(assignee => assignee.team_member_id) || []; return assignees.includes(memberId); }; const handleInviteProjectMemberDrawer = () => { setIsOpen(false); // Close the assignee dropdown first + dispatch(setIsFromAssigner(true)); dispatch(toggleProjectMemberDrawer()); // Then open the invite drawer }; @@ -217,12 +222,12 @@ const AssigneeSelector: React.FC = ({ className={` w-5 h-5 rounded-full border border-dashed flex items-center justify-center transition-colors duration-200 - ${isOpen - ? isDarkMode - ? 'border-blue-500 bg-blue-900/20 text-blue-400' + ${isOpen + ? isDarkMode + ? 'border-blue-500 bg-blue-900/20 text-blue-400' : 'border-blue-500 bg-blue-50 text-blue-600' - : isDarkMode - ? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400' + : isDarkMode + ? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400' : 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600' } `} @@ -236,8 +241,8 @@ const AssigneeSelector: React.FC = ({ onClick={e => e.stopPropagation()} className={` fixed z-[99999] w-72 rounded-md shadow-lg border - ${isDarkMode - ? 'bg-gray-800 border-gray-600' + ${isDarkMode + ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200' } `} @@ -273,10 +278,10 @@ const AssigneeSelector: React.FC = ({ key={member.id} className={` flex items-center gap-2 p-2 cursor-pointer transition-colors - ${member.pending_invitation - ? 'opacity-50 cursor-not-allowed' - : isDarkMode - ? 'hover:bg-gray-700' + ${member.pending_invitation + ? 'opacity-50 cursor-not-allowed' + : isDarkMode + ? 'hover:bg-gray-700' : 'hover:bg-gray-50' } `} @@ -301,23 +306,21 @@ const AssigneeSelector: React.FC = ({ /> {pendingChanges.has(member.id || '') && ( -
-
+
+
)}
- + - +
{member.name} @@ -339,22 +342,26 @@ const AssigneeSelector: React.FC = ({
{/* Footer */} -
- -
+ + {(isAdmin || isProjectManager) && ( +
+ +
+ )} +
, document.body )} diff --git a/worklenz-frontend/src/components/LicenseExpiredModal/LicenseExpiredModal.tsx b/worklenz-frontend/src/components/LicenseExpiredModal/LicenseExpiredModal.tsx new file mode 100644 index 000000000..304b8a423 --- /dev/null +++ b/worklenz-frontend/src/components/LicenseExpiredModal/LicenseExpiredModal.tsx @@ -0,0 +1,237 @@ +import { Modal, Button, Typography, Space, Card, Tag } from '@/shared/antd-imports'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { ClockCircleOutlined, CrownOutlined, CustomerServiceOutlined } from '@ant-design/icons'; +import { ISUBSCRIPTION_TYPE } from '@/shared/constants'; +import { supportApiService } from '@/api/support/support.api.service'; +import { useAuthService } from '@/hooks/useAuth'; + +const { Title, Text, Paragraph } = Typography; + +interface LicenseExpiredModalProps { + open: boolean; + subscriptionType?: ISUBSCRIPTION_TYPE; +} + +export const LicenseExpiredModal = ({ open, subscriptionType = ISUBSCRIPTION_TYPE.TRIAL }: LicenseExpiredModalProps) => { + const navigate = useNavigate(); + const { t } = useTranslation('common'); + const authService = useAuthService(); + const [visible, setVisible] = useState(open); + const [isContactingSupport, setIsContactingSupport] = useState(false); + const [messageSent, setMessageSent] = useState(false); + + useEffect(() => { + setVisible(open); + // Prevent scrolling when modal is open and add custom backdrop + if (open) { + document.body.style.overflow = 'hidden'; + + // Create custom backdrop that excludes navbar + const backdrop = document.createElement('div'); + backdrop.id = 'license-modal-backdrop'; + backdrop.style.cssText = ` + position: fixed; + top: 64px; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(4px); + z-index: 999; + pointer-events: none; + `; + document.body.appendChild(backdrop); + } + + return () => { + document.body.style.overflow = 'unset'; + const backdrop = document.getElementById('license-modal-backdrop'); + if (backdrop) { + document.body.removeChild(backdrop); + } + }; + }, [open]); + + const handleUpgrade = async () => { + if (subscriptionType === ISUBSCRIPTION_TYPE.CUSTOM) { + if (messageSent) return; // Prevent multiple clicks after message is sent + + try { + setIsContactingSupport(true); + + // Get current session data + const currentSession = authService?.getCurrentSession(); + + await supportApiService.contactSupport({ + subscription_type: subscriptionType, + reason: 'Custom plan renewal/support request' + }); + + setMessageSent(true); + // Success message is handled by the API client interceptor + } catch (error) { + console.error('Failed to contact support:', error); + // Error message is handled by the API client interceptor + } finally { + setIsContactingSupport(false); + } + } else { + navigate('/worklenz/admin-center/billing'); + } + }; + + // Get subscription-specific translations + const getTitle = () => { + switch (subscriptionType) { + case ISUBSCRIPTION_TYPE.TRIAL: + return t('license-expired-trial-title'); + case ISUBSCRIPTION_TYPE.CUSTOM: + return t('license-expired-custom-title'); + default: + return t('license-expired-title'); + } + }; + + const getSubtitle = () => { + switch (subscriptionType) { + case ISUBSCRIPTION_TYPE.TRIAL: + return t('license-expired-trial-subtitle'); + case ISUBSCRIPTION_TYPE.CUSTOM: + return t('license-expired-custom-subtitle'); + default: + return t('license-expired-subtitle'); + } + }; + + const getFeaturesTitle = () => { + switch (subscriptionType) { + case ISUBSCRIPTION_TYPE.TRIAL: + return t('license-expired-trial-features'); + case ISUBSCRIPTION_TYPE.CUSTOM: + return t('license-expired-custom-features'); + default: + return t('license-expired-features'); + } + }; + + const getUpgradeText = () => { + switch (subscriptionType) { + case ISUBSCRIPTION_TYPE.TRIAL: + return t('license-expired-trial-upgrade'); + case ISUBSCRIPTION_TYPE.CUSTOM: + return t('license-expired-custom-upgrade'); + default: + return t('license-expired-upgrade'); + } + }; + + const getUpgradeIcon = () => { + switch (subscriptionType) { + case ISUBSCRIPTION_TYPE.CUSTOM: + return ; + default: + return ; + } + }; + + const features = [ + t('license-expired-feature-1'), + t('license-expired-feature-2'), + t('license-expired-feature-3'), + t('license-expired-feature-4'), + ]; + + return ( + +
+ + {/* Icon and Title */} +
+ + + {getTitle()} + + + {getSubtitle()} + +
+ + {/* Features Card */} + + + + {getFeaturesTitle()} + + + {features.map((feature, index) => ( + + {feature} + + ))} + + + + + {/* Upgrade Button */} + + + {/* Note */} + + Note + {t('trial-alert-admin-note')} + +
+
+
+ ); +}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/TrialExpirationAlert/TrialExpirationAlert.tsx b/worklenz-frontend/src/components/TrialExpirationAlert/TrialExpirationAlert.tsx new file mode 100644 index 000000000..69633c449 --- /dev/null +++ b/worklenz-frontend/src/components/TrialExpirationAlert/TrialExpirationAlert.tsx @@ -0,0 +1,150 @@ +import { Alert, Button, Space } from '@/shared/antd-imports'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { useState, useEffect } from 'react'; +import { CloseOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; +import { useAuthService } from '@/hooks/useAuth'; +import { ISUBSCRIPTION_TYPE } from '@/shared/constants'; + +export const TrialExpirationAlert = () => { + const { t } = useTranslation('common'); + const navigate = useNavigate(); + const authService = useAuthService(); + const [visible, setVisible] = useState(true); + const [daysRemaining, setDaysRemaining] = useState(null); + + const currentSession = authService.getCurrentSession(); + + useEffect(() => { + // Check if user has already dismissed this alert today + const dismissedDate = localStorage.getItem('license-alert-dismissed'); + const today = new Date().toDateString(); + + if (dismissedDate === today) { + setVisible(false); + return; + } + + // Calculate days remaining for expirable subscription types + const expirableTypes = [ + ISUBSCRIPTION_TYPE.TRIAL, + ISUBSCRIPTION_TYPE.LIFE_TIME_DEAL, + ISUBSCRIPTION_TYPE.PADDLE, + ISUBSCRIPTION_TYPE.CUSTOM + ]; + + if ( + expirableTypes.includes(currentSession?.subscription_type as ISUBSCRIPTION_TYPE) && + (currentSession.valid_till_date || currentSession.trial_expire_date) + ) { + const today = new Date(); + const expireDateStr = currentSession.valid_till_date || currentSession.trial_expire_date; + const expiryDate = new Date(expireDateStr); + const diffTime = expiryDate.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + // Show alert if: + // 1. 3 days or less remaining before expiry (diffDays <= 3 && diffDays >= 0) + // 2. Within 7 days grace period after expiry (diffDays < 0 && diffDays >= -7) + if ((diffDays <= 3 && diffDays >= 0) || (diffDays < 0 && diffDays >= -7)) { + setDaysRemaining(diffDays); + setVisible(true); + } else { + setVisible(false); + } + } else { + setVisible(false); + } + }, [currentSession]); + + const handleClose = () => { + setVisible(false); + // Remember dismissal for today only + localStorage.setItem('license-alert-dismissed', new Date().toDateString()); + }; + + const handleUpgrade = () => { + navigate('/worklenz/admin-center/billing'); + }; + + if (!visible || daysRemaining === null) { + return null; + } + + const getAlertType = () => { + if (daysRemaining !== null && daysRemaining < 0) return 'error'; // Already expired + if (daysRemaining === 0) return 'error'; + if (daysRemaining === 1) return 'warning'; + return 'info'; + }; + + const getMessage = () => { + if (daysRemaining !== null && daysRemaining < 0) { + const daysExpired = Math.abs(daysRemaining); + const remainingGraceDays = 7 - daysExpired; + return t('license-expired-grace-period', { + days: remainingGraceDays, + count: remainingGraceDays + }); + } + if (daysRemaining === 0) { + return t('license-expiring-today'); + } + return t('license-expiring-soon', { days: daysRemaining, count: daysRemaining }); + }; + + return ( +
+ + + + {getMessage()} + + {t('license-expiring-upgrade')} + + + + +
+ ); +}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/account-setup/admin-center-common.css b/worklenz-frontend/src/components/account-setup/admin-center-common.css deleted file mode 100644 index f4c9c4215..000000000 --- a/worklenz-frontend/src/components/account-setup/admin-center-common.css +++ /dev/null @@ -1,19 +0,0 @@ -@media (max-width: 1000px) { - .step-content, - .step-form, - .create-first-task-form, - .setup-action-buttons, - .invite-members-form { - width: 400px !important; - } -} - -@media (max-width: 500px) { - .step-content, - .step-form, - .create-first-task-form, - .setup-action-buttons, - .invite-members-form { - width: 200px !important; - } -} diff --git a/worklenz-frontend/src/components/account-setup/members-step.tsx b/worklenz-frontend/src/components/account-setup/members-step.tsx index d3feedc1b..a9759f335 100644 --- a/worklenz-frontend/src/components/account-setup/members-step.tsx +++ b/worklenz-frontend/src/components/account-setup/members-step.tsx @@ -1,16 +1,15 @@ -import React, { useEffect, useRef } from 'react'; -import { Form, Input, Button, List, Alert, message, InputRef } from '@/shared/antd-imports'; -import { CloseCircleOutlined, MailOutlined, PlusOutlined } from '@/shared/antd-imports'; +import React, { useEffect, useRef, useState } from 'react'; +import { Form, Input, Button, Typography, Card, Avatar, Tag, Alert, Space, Dropdown, MenuProps } from '@/shared/antd-imports'; +import { CloseCircleOutlined, MailOutlined, PlusOutlined, UserOutlined, CheckCircleOutlined, ExclamationCircleOutlined, GlobalOutlined } from '@/shared/antd-imports'; import { useTranslation } from 'react-i18next'; -import { Typography } from '@/shared/antd-imports'; -import { setTeamMembers, setTasks } from '@/features/account-setup/account-setup.slice'; +import { setTeamMembers } from '@/features/account-setup/account-setup.slice'; import { useDispatch, useSelector } from 'react-redux'; import { RootState } from '@/app/store'; import { validateEmail } from '@/utils/validateEmail'; import { sanitizeInput } from '@/utils/sanitizeInput'; -import { Rule } from 'antd/es/form'; +import { setLanguage } from '@/features/i18n/localesSlice'; -const { Title } = Typography; +const { Title, Paragraph, Text } = Typography; interface Email { id: number; @@ -20,163 +19,233 @@ interface Email { interface MembersStepProps { isDarkMode: boolean; styles: any; + token?: any; } -const MembersStep: React.FC = ({ isDarkMode, styles }) => { - const { t } = useTranslation('account-setup'); +const getEmailSuggestions = (orgName?: string) => { + if (!orgName) return []; + const cleanOrgName = orgName.toLowerCase().replace(/[^a-z0-9]/g, ''); + return [`info@${cleanOrgName}.com`, `team@${cleanOrgName}.com`, `hello@${cleanOrgName}.com`, `contact@${cleanOrgName}.com`]; +}; + +const getRoleSuggestions = (t: any) => [ + { role: 'Designer', icon: '🎨', description: t('roleSuggestions.designer') }, + { role: 'Developer', icon: '💻', description: t('roleSuggestions.developer') }, + { role: 'Project Manager', icon: '📊', description: t('roleSuggestions.projectManager') }, + { role: 'Marketing', icon: '📢', description: t('roleSuggestions.marketing') }, + { role: 'Sales', icon: '💼', description: t('roleSuggestions.sales') }, + { role: 'Operations', icon: '⚙️', description: t('roleSuggestions.operations') } +]; + +const MembersStep: React.FC = ({ isDarkMode, styles, token }) => { + const { t, i18n } = useTranslation('account-setup'); const { teamMembers, organizationName } = useSelector( (state: RootState) => state.accountSetupReducer ); - const inputRefs = useRef<(InputRef | null)[]>([]); + const { language } = useSelector((state: RootState) => state.localesReducer); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); const dispatch = useDispatch(); - const [form] = Form.useForm(); + const [focusedIndex, setFocusedIndex] = useState(null); + const [showSuggestions, setShowSuggestions] = useState(false); + const [validatedEmails, setValidatedEmails] = useState>(new Set()); - const addEmail = () => { - if (teamMembers.length == 5) return; + const emailSuggestions = getEmailSuggestions(organizationName); + const addEmail = () => { + if (teamMembers.length >= 5) return; const newId = teamMembers.length > 0 ? Math.max(...teamMembers.map(t => t.id)) + 1 : 0; dispatch(setTeamMembers([...teamMembers, { id: newId, value: '' }])); - setTimeout(() => { - inputRefs.current[newId]?.focus(); - }, 0); + setTimeout(() => inputRefs.current[teamMembers.length]?.focus(), 100); }; const removeEmail = (id: number) => { - if (teamMembers.length > 1) { - dispatch(setTeamMembers(teamMembers.filter(teamMember => teamMember.id !== id))); - } + if (teamMembers.length > 1) dispatch(setTeamMembers(teamMembers.filter(teamMember => teamMember.id !== id))); }; const updateEmail = (id: number, value: string) => { const sanitizedValue = sanitizeInput(value); - dispatch( - setTeamMembers( - teamMembers.map(teamMember => - teamMember.id === id ? { ...teamMember, value: sanitizedValue } : teamMember - ) - ) - ); + dispatch(setTeamMembers(teamMembers.map(teamMember => teamMember.id === id ? { ...teamMember, value: sanitizedValue } : teamMember))); }; - const handleKeyPress = (e: React.KeyboardEvent) => { - const input = e.currentTarget as HTMLInputElement; - if (!input.value.trim()) return; - e.preventDefault(); - addEmail(); + const handleKeyPress = (e: React.KeyboardEvent, index: number) => { + if (e.key === 'Enter') { + const input = e.currentTarget as HTMLInputElement; + if (input.value.trim() && validateEmail(input.value.trim())) { + e.preventDefault(); + if (index === teamMembers.length - 1 && teamMembers.length < 5) addEmail(); + else if (index < teamMembers.length - 1) inputRefs.current[index + 1]?.focus(); + } + } }; - // Function to set ref that doesn't return anything (void) - const setInputRef = (index: number) => (el: InputRef | null) => { - inputRefs.current[index] = el; + const handleSuggestionClick = (suggestion: string) => { + const emptyEmailIndex = teamMembers.findIndex(member => !member.value.trim()); + if (emptyEmailIndex !== -1) { + updateEmail(teamMembers[emptyEmailIndex].id, suggestion); + } else if (teamMembers.length < 5) { + const newId = teamMembers.length > 0 ? Math.max(...teamMembers.map(t => t.id)) + 1 : 0; + dispatch(setTeamMembers([...teamMembers, { id: newId, value: suggestion }])); + } + setShowSuggestions(false); }; useEffect(() => { - setTimeout(() => { - inputRefs.current[teamMembers.length - 1]?.focus(); - // Set initial form values - const initialValues: Record = {}; - teamMembers.forEach(teamMember => { - initialValues[`email-${teamMember.id}`] = teamMember.value; - }); - form.setFieldsValue(initialValues); - }, 200); + setTimeout(() => inputRefs.current[0]?.focus(), 200); }, []); - const formRules = { - email: [ - { - validator: async (_: any, value: string) => { - if (!value) return; - if (!validateEmail(value)) { - throw new Error(t('invalidEmail')); - } - }, - }, - ], + const getEmailStatus = (email: string, memberId: number) => { + if (!email.trim()) return 'empty'; + if (!validatedEmails.has(memberId)) return 'empty'; + return validateEmail(email) ? 'valid' : 'invalid'; + }; + + const handleBlur = (memberId: number, email: string) => { + setFocusedIndex(null); + if (email.trim()) setValidatedEmails(prev => new Set(prev).add(memberId)); }; + const languages = [ + { key: 'en', label: t('languages.en'), flag: '🇺🇸' }, + { key: 'es', label: t('languages.es'), flag: '🇪🇸' }, + { key: 'pt', label: t('languages.pt'), flag: '🇵🇹' }, + { key: 'de', label: t('languages.de'), flag: '🇩🇪' }, + { key: 'alb', label: t('languages.alb'), flag: '🇦🇱' }, + { key: 'zh', label: t('languages.zh'), flag: '🇨🇳' } + ]; + + const handleLanguageChange = (languageKey: string) => { + dispatch(setLanguage(languageKey)); + i18n.changeLanguage(languageKey); + }; + + const currentLanguage = languages.find(lang => lang.key === language) || languages[0]; + const languageMenuItems: MenuProps['items'] = languages.map(lang => ({ key: lang.key, label:
{lang.flag}{lang.label}
, onClick: () => handleLanguageChange(lang.key) })); + return ( -
- - - {t('step3Title')} "<mark>{organizationName}</mark>". + <div className="w-full members-step"> + {/* Header */} + <div className="text-center mb-8"> + <Title level={3} className="mb-2" style={{ color: token?.colorText }}> + {t('membersStepTitle')} - - - {t('step3InputLabel')}  {t('maxMembers')} - - } - > - ( - -
- + + {t('membersStepDescription', { organizationName })} + +
+ + {/* Team Members List */} +
+
+ {teamMembers.map((teamMember, index) => { + const emailStatus = getEmailStatus(teamMember.value, teamMember.id); + return ( +
+ : + emailStatus === 'invalid' ? : + + } + /> + +
updateEmail(teamMember.id, e.target.value)} - onPressEnter={handleKeyPress} - ref={setInputRef(index)} - status={teamMember.value && !validateEmail(teamMember.value) ? 'error' : ''} - id={`member-${index}`} + onKeyPress={e => handleKeyPress(e, index)} + onFocus={() => setFocusedIndex(index)} + onBlur={() => handleBlur(teamMember.id, teamMember.value)} + ref={el => inputRefs.current[index] = el} + className="border-0 shadow-none" + style={{ + backgroundColor: 'transparent', + color: token?.colorText + }} + prefix={} + status={emailStatus === 'invalid' ? 'error' : undefined} + suffix={ + emailStatus === 'valid' ? ( + + ) : emailStatus === 'invalid' ? ( + + ) : null + } /> - -
+ + {teamMembers.length > 1 && ( +
- - )} - /> - -
+ + {/* Add Member Button */} + {teamMembers.length < 5 && ( + + )} +
+ + {/* Skip Option */} +
+
- - + /> +
+
); }; -export default MembersStep; +export default MembersStep; \ No newline at end of file diff --git a/worklenz-frontend/src/components/account-setup/organization-step.tsx b/worklenz-frontend/src/components/account-setup/organization-step.tsx index ec69b9996..87e4ab9a6 100644 --- a/worklenz-frontend/src/components/account-setup/organization-step.tsx +++ b/worklenz-frontend/src/components/account-setup/organization-step.tsx @@ -1,31 +1,40 @@ -import React, { useEffect, useRef } from 'react'; -import { Form, Input, InputRef, Typography } from '@/shared/antd-imports'; +import React, { useEffect, useRef, useState } from 'react'; +import { Form, Input, InputRef, Typography, Card, Tooltip } from '@/shared/antd-imports'; import { useDispatch, useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { setOrganizationName } from '@/features/account-setup/account-setup.slice'; import { RootState } from '@/app/store'; import { sanitizeInput } from '@/utils/sanitizeInput'; -import './admin-center-common.css'; -const { Title } = Typography; +const { Title, Paragraph, Text } = Typography; interface Props { onEnter: () => void; styles: any; organizationNamePlaceholder: string; + organizationNameInitialValue?: string; + isDarkMode: boolean; + token?: any; } export const OrganizationStep: React.FC = ({ onEnter, styles, organizationNamePlaceholder, + organizationNameInitialValue, + isDarkMode, + token, }) => { const { t } = useTranslation('account-setup'); const dispatch = useDispatch(); const { organizationName } = useSelector((state: RootState) => state.accountSetupReducer); const inputRef = useRef(null); + // Autofill organization name if not already set useEffect(() => { + if (!organizationName && organizationNameInitialValue) { + dispatch(setOrganizationName(organizationNameInitialValue)); + } setTimeout(() => inputRef.current?.focus(), 300); }, []); @@ -40,25 +49,85 @@ export const OrganizationStep: React.FC = ({ }; return ( -
- - - {t('organizationStepTitle')} + <div className="w-full organization-step"> + {/* Header */} + <div className="text-center mb-8"> + <Title level={3} className="mb-2" style={{ color: token?.colorText }}> + {t('organizationStepWelcome')} - - {t('organizationStepLabel')}} + + {t('organizationStepDescription')} + +
+ + {/* Main Form Card */} +
+ + + + {t('organizationStepLabel')} + + + + ⓘ + + +
+ } + > + + + + {/* Character Count and Validation */} +
+ + {organizationName.length}/50 {t('organizationStepCharacters')} + + {organizationName.length > 0 && ( +
+ {organizationName.length >= 2 ? ( + ✓ {t('organizationStepGoodLength')} + ) : ( + ⚠ {t('organizationStepTooShort')} + )} +
+ )} +
+ +
+ + {/* Footer Note */} +
- - - + + 🔒 {t('organizationStepPrivacyNote')} + +
+ ); -}; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/account-setup/project-step.tsx b/worklenz-frontend/src/components/account-setup/project-step.tsx index 1447bfd83..4810859b2 100644 --- a/worklenz-frontend/src/components/account-setup/project-step.tsx +++ b/worklenz-frontend/src/components/account-setup/project-step.tsx @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { Button, Drawer, Form, Input, InputRef, Select, Typography } from '@/shared/antd-imports'; +import { Button, Drawer, Form, Input, InputRef, Typography, Card, Row, Col, Tag, Tooltip, Spin, Alert } from '@/shared/antd-imports'; import TemplateDrawer from '../common/template-drawer/template-drawer'; import { RootState } from '@/app/store'; @@ -13,7 +13,7 @@ import { sanitizeInput } from '@/utils/sanitizeInput'; import { projectTemplatesApiService } from '@/api/project-templates/project-templates.api.service'; import logger from '@/utils/errorLogger'; -import { IAccountSetupRequest } from '@/types/project-templates/project-templates.types'; +import { IAccountSetupRequest, IWorklenzTemplate, IProjectTemplate } from '@/types/project-templates/project-templates.types'; import { evt_account_setup_template_complete } from '@/shared/worklenz-analytics-events'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; @@ -24,15 +24,43 @@ import { setUser } from '@/features/user/userSlice'; import { setSession } from '@/utils/session-helper'; import { IAuthorizeResponse } from '@/types/auth/login.types'; -const { Title } = Typography; +const { Title, Paragraph, Text } = Typography; interface Props { onEnter: () => void; styles: any; isDarkMode: boolean; + token?: any; } -export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = false }) => { +// Default icon mapping for templates (fallback if no image_url) +const getTemplateIcon = (name?: string) => { + if (!name) return '📁'; + const lowercaseName = name.toLowerCase(); + if (lowercaseName.includes('software') || lowercaseName.includes('development')) return '💻'; + if (lowercaseName.includes('marketing') || lowercaseName.includes('campaign')) return '📢'; + if (lowercaseName.includes('construction') || lowercaseName.includes('building')) return '🏗️'; + if (lowercaseName.includes('startup') || lowercaseName.includes('launch')) return '🚀'; + if (lowercaseName.includes('design') || lowercaseName.includes('creative')) return '🎨'; + if (lowercaseName.includes('education') || lowercaseName.includes('learning')) return '📚'; + if (lowercaseName.includes('event') || lowercaseName.includes('planning')) return '📅'; + if (lowercaseName.includes('retail') || lowercaseName.includes('sales')) return '🛍️'; + return '📁'; +}; + +const getProjectSuggestions = (orgType?: string) => { + const suggestions: Record = { + 'freelancer': ['Client Website', 'Logo Design', 'Content Writing', 'App Development'], + 'startup': ['MVP Development', 'Product Launch', 'Marketing Campaign', 'Investor Pitch'], + 'small_medium_business': ['Q1 Sales Initiative', 'Website Redesign', 'Process Improvement', 'Team Training'], + 'agency': ['Client Campaign', 'Brand Strategy', 'Website Project', 'Creative Brief'], + 'enterprise': ['Digital Transformation', 'System Migration', 'Annual Planning', 'Department Initiative'], + 'other': ['New Project', 'Team Initiative', 'Q1 Goals', 'Special Project'] + }; + return suggestions[orgType || 'other'] || suggestions['other']; +}; + +export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = false, token }) => { const { t } = useTranslation('account-setup'); const dispatch = useAppDispatch(); const navigate = useNavigate(); @@ -42,13 +70,58 @@ export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = fal useEffect(() => { setTimeout(() => inputRef.current?.focus(), 200); + fetchTemplates(); }, []); - const { projectName, templateId, organizationName } = useSelector( + const fetchTemplates = async () => { + try { + setLoadingTemplates(true); + setTemplateError(null); + + // Fetch list of available templates + const templatesResponse = await projectTemplatesApiService.getWorklenzTemplates(); + + if (templatesResponse.done && templatesResponse.body) { + // Fetch detailed information for first 4 templates for preview + const templateDetails = await Promise.all( + templatesResponse.body.slice(0, 4).map(async (template) => { + if (template.id) { + try { + const detailResponse = await projectTemplatesApiService.getByTemplateId(template.id); + return detailResponse.done ? detailResponse.body : null; + } catch (error) { + logger.error(`Failed to fetch template details for ${template.id}`, error); + return null; + } + } + return null; + }) + ); + + // Filter out null results and set templates + const validTemplates = templateDetails.filter((template): template is IProjectTemplate => template !== null); + setTemplates(validTemplates); + } + } catch (error) { + logger.error('Failed to fetch templates', error); + setTemplateError('Failed to load templates'); + } finally { + setLoadingTemplates(false); + } + }; + + + const { projectName, templateId, organizationName, surveyData } = useSelector( (state: RootState) => state.accountSetupReducer ); const [open, setOpen] = useState(false); const [creatingFromTemplate, setCreatingFromTemplate] = useState(false); + const [selectedTemplate, setSelectedTemplate] = useState(templateId || null); + const [templates, setTemplates] = useState([]); + const [loadingTemplates, setLoadingTemplates] = useState(true); + const [templateError, setTemplateError] = useState(null); + + const projectSuggestions = getProjectSuggestions(surveyData.organization_type); const handleTemplateSelected = (templateId: string) => { if (!templateId) return; @@ -74,8 +147,6 @@ export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = fal if (res.done && res.body.id) { toggleTemplateSelector(false); trackMixpanelEvent(evt_account_setup_template_complete); - - // Refresh user session to update setup_completed status try { const authResponse = await dispatch(verifyAuthentication()).unwrap() as IAuthorizeResponse; if (authResponse?.authenticated && authResponse?.user) { @@ -85,7 +156,6 @@ export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = fal } catch (error) { logger.error('Failed to refresh user session after template setup completion', error); } - navigate(`/worklenz/projects/${res.body.id}?tab=tasks-list&pinned_tab=tasks-list`); } } catch (error) { @@ -94,8 +164,7 @@ export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = fal }; const onPressEnter = () => { - if (!projectName.trim()) return; - onEnter(); + if (projectName.trim()) onEnter(); }; const handleProjectNameChange = (e: React.ChangeEvent) => { @@ -103,43 +172,205 @@ export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = fal dispatch(setProjectName(sanitizedValue)); }; + const handleProjectNameFocus = () => { + if (templateId) { + dispatch(setTemplateId(null)); + setSelectedTemplate(null); + } + }; + + const handleSuggestionClick = (suggestion: string) => { + dispatch(setProjectName(suggestion)); + inputRef.current?.focus(); + }; + return ( -
-
- - - {t('projectStepTitle')} - - - {t('projectStepLabel')}} - > - - -
-
- - {t('or')} + <div className="w-full project-step"> + {/* Header */} + <div className="text-center mb-8"> + <Title level={3} className="mb-2" style={{ color: token?.colorText }}> + {t('projectStepHeader')} -
+ + {t('projectStepSubheader')} + +
+ + {/* Project Name Section */} +
+ +
+
+ + {t('startFromScratch')} + + {templateId && ( + + {t('templateSelected')} + + )} +
+
+ + {t('projectStepLabel')}} + > + + + +
+ {t('quickSuggestions')} +
+ {projectSuggestions.map((suggestion, index) => ( + + ))} +
+
+
-
- +
+
+
+
+
+ {t('orText')} +
+ +
+
+ {t('startWithTemplate')} + + {t('templateHeadStart')} + +
+ + {/* Template Preview Cards */} +
+ {loadingTemplates ? ( +
+ +
+ Loading templates... +
+
+ ) : templateError ? ( + + Retry + + } + /> + ) : ( + + {templates.map((template) => ( + + { + setSelectedTemplate(template.id || null); + dispatch(setTemplateId(template.id || '')); + }} + > +
+ {template.image_url ? ( + {template.name} { + // Fallback to icon if image fails to load + e.currentTarget.style.display = 'none'; + if (e.currentTarget.nextSibling) { + (e.currentTarget.nextSibling as HTMLElement).style.display = 'block'; + } + }} + /> + ) : null} + + {getTemplateIcon(template.name)} + +
+ + {template.name || 'Untitled Template'} + +
+ {template.phases?.slice(0, 3).map((phase, index) => ( + + {phase.name} + + ))} + {(template.phases?.length || 0) > 3 && ( + +{(template.phases?.length || 0) - 3} more + )} +
+
+
+
+ + ))} +
+ )} +
+ +
+ +
+ {t('templatesAvailable')} +
+
+
+ + {/* Template Drawer */} {createPortal( + + {t('templateDrawerTitle')} + + + {t('chooseTemplate')} + +
+ } width={1000} onClose={() => toggleTemplateSelector(false)} open={open} @@ -152,11 +383,13 @@ export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = fal type="primary" onClick={() => createFromTemplate()} loading={creatingFromTemplate} + disabled={!templateId} > - {t('create')} +{t('createProject')}
} + style={{ backgroundColor: token?.colorBgLayout }} > = ({ onEnter, styles, isDarkMode = fal )}
); -}; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/account-setup/survey-step.tsx b/worklenz-frontend/src/components/account-setup/survey-step.tsx new file mode 100644 index 000000000..17efc3d22 --- /dev/null +++ b/worklenz-frontend/src/components/account-setup/survey-step.tsx @@ -0,0 +1,374 @@ +import React from 'react'; +import { Form, Input, Typography, Button, Progress, Space } from '@/shared/antd-imports'; +import { useDispatch, useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { setSurveyData, setSurveySubStep } from '@/features/account-setup/account-setup.slice'; +import { RootState } from '@/app/store'; +import { + OrganizationType, + UserRole, + UseCase, + HowHeardAbout, + IAccountSetupSurveyData +} from '@/types/account-setup/survey.types'; + +const { Title, Paragraph } = Typography; +const { TextArea } = Input; + +interface Props { + onEnter: () => void; + styles: any; + isDarkMode: boolean; + token?: any; + isModal?: boolean; // New prop to indicate if used in modal context +} + +interface SurveyPageProps { + styles: any; + isDarkMode: boolean; + token?: any; + surveyData: IAccountSetupSurveyData; + handleSurveyDataChange: (field: keyof IAccountSetupSurveyData, value: any) => void; + handleUseCaseToggle?: (value: UseCase) => void; + isModal?: boolean; +} + +// Page 1: About You +const AboutYouPage: React.FC = ({ styles, token, surveyData, handleSurveyDataChange }) => { + const { t } = useTranslation('account-setup'); + + const organizationTypeOptions: { value: OrganizationType; label: string; icon?: string }[] = [ + { value: 'freelancer', label: t('organizationTypeFreelancer'), icon: '👤' }, + { value: 'startup', label: t('organizationTypeStartup'), icon: '🚀' }, + { value: 'small_medium_business', label: t('organizationTypeSmallMediumBusiness'), icon: '🏢' }, + { value: 'agency', label: t('organizationTypeAgency'), icon: '🎯' }, + { value: 'enterprise', label: t('organizationTypeEnterprise'), icon: '🏛️' }, + { value: 'other', label: t('organizationTypeOther'), icon: '📋' }, + ]; + + const userRoleOptions: { value: UserRole; label: string; icon?: string }[] = [ + { value: 'founder_ceo', label: t('userRoleFounderCeo'), icon: '👔' }, + { value: 'project_manager', label: t('userRoleProjectManager'), icon: '📊' }, + { value: 'software_developer', label: t('userRoleSoftwareDeveloper'), icon: '💻' }, + { value: 'designer', label: t('userRoleDesigner'), icon: '🎨' }, + { value: 'operations', label: t('userRoleOperations'), icon: '⚙️' }, + { value: 'other', label: t('userRoleOther'), icon: '✋' }, + ]; + + return ( +
+
+ + {t('aboutYouStepTitle')} + + + {t('aboutYouStepDescription')} + +
+ + {/* Organization Type */} + + +
+ {organizationTypeOptions.map((option) => { + const isSelected = surveyData.organization_type === option.value; + return ( + + ); + })} +
+
+ + {/* User Role */} + + +
+ {userRoleOptions.map((option) => { + const isSelected = surveyData.user_role === option.value; + return ( + + ); + })} +
+
+
+ ); +}; + +// Page 2: Your Needs +const YourNeedsPage: React.FC = ({ styles, token, surveyData, handleSurveyDataChange, handleUseCaseToggle }) => { + const { t } = useTranslation('account-setup'); + + const useCaseOptions: { value: UseCase; label: string; description: string }[] = [ + { value: 'task_management', label: t('mainUseCasesTaskManagement'), description: 'Organize and track tasks' }, + { value: 'team_collaboration', label: t('mainUseCasesTeamCollaboration'), description: 'Work together seamlessly' }, + { value: 'resource_planning', label: t('mainUseCasesResourcePlanning'), description: 'Manage time and resources' }, + { value: 'client_communication', label: t('mainUseCasesClientCommunication'), description: 'Stay connected with clients' }, + { value: 'time_tracking', label: t('mainUseCasesTimeTracking'), description: 'Monitor project hours' }, + { value: 'other', label: t('mainUseCasesOther'), description: 'Something else' }, + ]; + + const onUseCaseClick = (value: UseCase) => { + if (handleUseCaseToggle) { + handleUseCaseToggle(value); + } else { + const currentUseCases = surveyData.main_use_cases || []; + const isSelected = currentUseCases.includes(value); + const newUseCases = isSelected ? currentUseCases.filter(useCase => useCase !== value) : [...currentUseCases, value]; + handleSurveyDataChange('main_use_cases', newUseCases); + } + }; + + return ( +
+
+ + {t('yourNeedsStepTitle')} + + + {t('yourNeedsStepDescription')} + +
+ + {/* Main Use Cases */} + + +
+ {useCaseOptions.map((option) => { + const isSelected = (surveyData.main_use_cases || []).includes(option.value); + return ( + + ); + })} +
+ {surveyData.main_use_cases && surveyData.main_use_cases.length > 0 && ( +

+ {surveyData.main_use_cases.length} {t('selected')} +

+ )} +
+ + {/* Previous Tools */} + + +