diff --git a/backup.sh b/backup.sh new file mode 100644 index 00000000..8f16e1c7 --- /dev/null +++ b/backup.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -eu + +# Adjust these as needed: +CONTAINER=worklenz_db +DB_NAME=worklenz_db +DB_USER=postgres +BACKUP_DIR=./pg_backups +mkdir -p "$BACKUP_DIR" + +timestamp=$(date +%Y-%m-%d_%H-%M-%S) +outfile="${BACKUP_DIR}/${DB_NAME}_${timestamp}.sql" +echo "Creating backup $outfile ..." + +docker exec -t "$CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" > "$outfile" +echo "Backup saved to $outfile" diff --git a/docker-compose.yml b/docker-compose.yml index 363f2006..6522ddff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -83,7 +83,11 @@ services: POSTGRES_DB: ${DB_NAME:-worklenz_db} POSTGRES_PASSWORD: ${DB_PASSWORD:-password} healthcheck: - test: [ "CMD-SHELL", "pg_isready -d ${DB_NAME:-worklenz_db} -U ${DB_USER:-postgres}" ] + test: + [ + "CMD-SHELL", + "pg_isready -d ${DB_NAME:-worklenz_db} -U ${DB_USER:-postgres}", + ] interval: 10s timeout: 5s retries: 5 @@ -93,23 +97,65 @@ services: volumes: - worklenz_postgres_data:/var/lib/postgresql/data - type: bind - source: ./worklenz-backend/database - target: /docker-entrypoint-initdb.d + source: ./worklenz-backend/database/sql + target: /docker-entrypoint-initdb.d/sql consistency: cached + - type: bind + source: ./worklenz-backend/database/migrations + target: /docker-entrypoint-initdb.d/migrations + consistency: cached + - type: bind + source: ./worklenz-backend/database/00_init.sh + target: /docker-entrypoint-initdb.d/00_init.sh + consistency: cached + - type: bind + source: ./pg_backups + target: /docker-entrypoint-initdb.d/pg_backups command: > - bash -c ' if command -v apt-get >/dev/null 2>&1; then - apt-get update && apt-get install -y dos2unix - elif command -v apk >/dev/null 2>&1; then - apk add --no-cache dos2unix - fi && find /docker-entrypoint-initdb.d -type f -name "*.sh" -exec sh -c '\'' - dos2unix "{}" 2>/dev/null || true - chmod +x "{}" - '\'' \; && exec docker-entrypoint.sh postgres ' + bash -c ' + if command -v apt-get >/dev/null 2>&1; then + apt-get update && apt-get install -y dos2unix + elif command -v apk >/dev/null 2>&1; then + apk add --no-cache dos2unix + fi + + find /docker-entrypoint-initdb.d -type f -name "*.sh" -exec sh -c '"'"' + for f; do + dos2unix "$f" 2>/dev/null || true + chmod +x "$f" + done + '"'"' sh {} + + + exec docker-entrypoint.sh postgres + ' + db-backup: + image: postgres:15 + container_name: worklenz_db_backup + environment: + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_DB: ${DB_NAME:-worklenz_db} + POSTGRES_PASSWORD: ${DB_PASSWORD:-password} + depends_on: + db: + condition: service_healthy + volumes: + - ./pg_backups:/pg_backups #host dir for backups files + #setup bassh loop to backup data evey 24h + command: > + bash -c 'while true; do + sleep 86400; + PGPASSWORD=$$POSTGRES_PASSWORD pg_dump -h worklenz_db -U $$POSTGRES_USER -d $$POSTGRES_DB \ + > /pg_backups/worklenz_db_$$(date +%Y-%m-%d_%H-%M-%S).sql; + find /pg_backups -type f -name "*.sql" -mtime +30 -delete; + done' + restart: unless-stopped + networks: + - worklenz volumes: worklenz_postgres_data: worklenz_minio_data: - + pgdata: networks: worklenz: diff --git a/worklenz-backend/database/00-init-db.sh b/worklenz-backend/database/00-init-db.sh deleted file mode 100644 index 9743d435..00000000 --- a/worklenz-backend/database/00-init-db.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/bash -set -e - -# This script controls the order of SQL file execution during database initialization -echo "Starting database initialization..." - -# Check if we have SQL files in expected locations -if [ -f "/docker-entrypoint-initdb.d/sql/0_extensions.sql" ]; then - SQL_DIR="/docker-entrypoint-initdb.d/sql" - echo "Using SQL files from sql/ subdirectory" -elif [ -f "/docker-entrypoint-initdb.d/0_extensions.sql" ]; then - # First time setup - move files to subdirectory - echo "Moving SQL files to sql/ subdirectory..." - mkdir -p /docker-entrypoint-initdb.d/sql - - # Move all SQL files (except this script) to the subdirectory - for f in /docker-entrypoint-initdb.d/*.sql; do - if [ -f "$f" ]; then - cp "$f" /docker-entrypoint-initdb.d/sql/ - echo "Copied $f to sql/ subdirectory" - fi - done - - SQL_DIR="/docker-entrypoint-initdb.d/sql" -else - echo "SQL files not found in expected locations!" - exit 1 -fi - -# Execute SQL files in the correct order -echo "Executing 0_extensions.sql..." -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/0_extensions.sql" - -echo "Executing 1_tables.sql..." -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/1_tables.sql" - -echo "Executing indexes.sql..." -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/indexes.sql" - -echo "Executing 4_functions.sql..." -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/4_functions.sql" - -echo "Executing triggers.sql..." -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/triggers.sql" - -echo "Executing 3_views.sql..." -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/3_views.sql" - -echo "Executing 2_dml.sql..." -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/2_dml.sql" - -echo "Executing 5_database_user.sql..." -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/5_database_user.sql" - -echo "Database initialization completed successfully" \ No newline at end of file diff --git a/worklenz-backend/database/00_init.sh b/worklenz-backend/database/00_init.sh new file mode 100644 index 00000000..afd8562a --- /dev/null +++ b/worklenz-backend/database/00_init.sh @@ -0,0 +1,88 @@ +#!/bin/bash +set -e + +echo "Starting database initialization..." + +SQL_DIR="/docker-entrypoint-initdb.d/sql" +MIGRATIONS_DIR="/docker-entrypoint-initdb.d/migrations" +BACKUP_DIR="/docker-entrypoint-initdb.d/pg_backups" + +# -------------------------------------------- +# 🗄️ STEP 1: Attempt to restore latest backup +# -------------------------------------------- + +if [ -d "$BACKUP_DIR" ]; then + LATEST_BACKUP=$(ls -t "$BACKUP_DIR"/*.sql 2>/dev/null | head -n 1) +else + LATEST_BACKUP="" +fi + +if [ -f "$LATEST_BACKUP" ]; then + echo "🗄️ Found latest backup: $LATEST_BACKUP" + echo "⏳ Restoring from backup..." + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" < "$LATEST_BACKUP" + echo "✅ Backup restoration complete. Skipping schema and migrations." + exit 0 +else + echo "ℹ️ No valid backup found. Proceeding with base schema and migrations." +fi + +# -------------------------------------------- +# 🏗️ STEP 2: Continue with base schema setup +# -------------------------------------------- + +# Create migrations table if it doesn't exist +psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c " + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMP DEFAULT now() + ); +" + +# List of base schema files to execute in order +BASE_SQL_FILES=( + "0_extensions.sql" + "1_tables.sql" + "indexes.sql" + "4_functions.sql" + "triggers.sql" + "3_views.sql" + "2_dml.sql" + "5_database_user.sql" +) + +echo "Running base schema SQL files in order..." + +for file in "${BASE_SQL_FILES[@]}"; do + full_path="$SQL_DIR/$file" + if [ -f "$full_path" ]; then + echo "Executing $file..." + psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f "$full_path" + else + echo "WARNING: $file not found, skipping." + fi +done + +echo "✅ Base schema SQL execution complete." + +# -------------------------------------------- +# 🚀 STEP 3: Apply SQL migrations +# -------------------------------------------- + +if [ -d "$MIGRATIONS_DIR" ] && compgen -G "$MIGRATIONS_DIR/*.sql" > /dev/null; then + echo "Applying migrations..." + for f in "$MIGRATIONS_DIR"/*.sql; do + version=$(basename "$f") + if ! psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT 1 FROM schema_migrations WHERE version = '$version'" | grep -q 1; then + echo "Applying migration: $version" + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f "$f" + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "INSERT INTO schema_migrations (version) VALUES ('$version');" + else + echo "Skipping already applied migration: $version" + fi + done +else + echo "No migration files found or directory is empty, skipping migrations." +fi + +echo "🎉 Database initialization completed successfully." diff --git a/worklenz-backend/database/migrations/20250115000000-performance-indexes.sql b/worklenz-backend/database/migrations/20250115000000-performance-indexes.sql new file mode 100644 index 00000000..791c6f02 --- /dev/null +++ b/worklenz-backend/database/migrations/20250115000000-performance-indexes.sql @@ -0,0 +1,135 @@ +-- Performance indexes for optimized tasks queries +-- Migration: 20250115000000-performance-indexes.sql + +-- Composite index for main task filtering +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_archived_parent +ON tasks(project_id, archived, parent_task_id) +WHERE archived = FALSE; + +-- Index for status joins +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_status_project +ON tasks(status_id, project_id) +WHERE archived = FALSE; + +-- Index for assignees lookup +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_assignees_task_member +ON tasks_assignees(task_id, team_member_id); + +-- Index for phase lookup +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_phase_task_phase +ON task_phase(task_id, phase_id); + +-- Index for subtask counting +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_archived +ON tasks(parent_task_id, archived) +WHERE parent_task_id IS NOT NULL AND archived = FALSE; + +-- Index for labels +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_labels_task_label +ON task_labels(task_id, label_id); + +-- Index for comments count +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_comments_task +ON task_comments(task_id); + +-- Index for attachments count +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_attachments_task +ON task_attachments(task_id); + +-- Index for work log aggregation +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_work_log_task +ON task_work_log(task_id); + +-- Index for subscribers check +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_subscribers_task +ON task_subscribers(task_id); + +-- Index for dependencies check +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_task +ON task_dependencies(task_id); + +-- Index for timers lookup +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_task_user +ON task_timers(task_id, user_id); + +-- Index for custom columns +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cc_column_values_task +ON cc_column_values(task_id); + +-- Index for team member info view optimization +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_team_user +ON team_members(team_id, user_id) +WHERE active = TRUE; + +-- Index for notification settings +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_notification_settings_user_team +ON notification_settings(user_id, team_id); + +-- Index for task status categories +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_category +ON task_statuses(category_id, project_id); + +-- Index for project phases +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_project_phases_project_sort +ON project_phases(project_id, sort_index); + +-- Index for task priorities +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_priorities_value +ON task_priorities(value); + +-- Index for team labels +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_labels_team +ON team_labels(team_id); + +-- NEW INDEXES FOR PERFORMANCE OPTIMIZATION -- + +-- Composite index for task main query optimization (covers most WHERE conditions) +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_performance_main +ON tasks(project_id, archived, parent_task_id, status_id, priority_id) +WHERE archived = FALSE; + +-- Index for sorting by sort_order with project filter +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_sort_order +ON tasks(project_id, sort_order) +WHERE archived = FALSE; + +-- Index for email_invitations to optimize team_member_info_view +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_invitations_team_member +ON email_invitations(team_member_id); + +-- Covering index for task status with category information +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_covering +ON task_statuses(id, category_id, project_id); + +-- Index for task aggregation queries (parent task progress calculation) +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_status_archived +ON tasks(parent_task_id, status_id, archived) +WHERE archived = FALSE; + +-- Index for project team member filtering +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_project_lookup +ON team_members(team_id, active, user_id) +WHERE active = TRUE; + +-- Covering index for tasks with frequently accessed columns +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_covering_main +ON tasks(id, project_id, archived, parent_task_id, status_id, priority_id, sort_order, name) +WHERE archived = FALSE; + +-- Index for task search functionality +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_name_search +ON tasks USING gin(to_tsvector('english', name)) +WHERE archived = FALSE; + +-- Index for date-based filtering (if used) +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_dates +ON tasks(project_id, start_date, end_date) +WHERE archived = FALSE; + +-- Index for task timers with user filtering +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_user_task +ON task_timers(user_id, task_id); + +-- Index for sys_task_status_categories lookups +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sys_task_status_categories_covering +ON sys_task_status_categories(id, color_code, color_code_dark, is_done, is_doing, is_todo); \ 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 af6cdc0e..21f498f1 100644 --- a/worklenz-backend/database/sql/1_tables.sql +++ b/worklenz-backend/database/sql/1_tables.sql @@ -12,7 +12,7 @@ CREATE TYPE DEPENDENCY_TYPE AS ENUM ('blocked_by'); CREATE TYPE SCHEDULE_TYPE AS ENUM ('daily', 'weekly', 'yearly', 'monthly', 'every_x_days', 'every_x_weeks', 'every_x_months'); -CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt'); +CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt', 'alb', 'de', 'zh_cn'); -- START: Users CREATE SEQUENCE IF NOT EXISTS users_user_no_seq START 1; diff --git a/worklenz-backend/database/sql/3_views.sql b/worklenz-backend/database/sql/3_views.sql index 15e36e23..f29291de 100644 --- a/worklenz-backend/database/sql/3_views.sql +++ b/worklenz-backend/database/sql/3_views.sql @@ -32,3 +32,37 @@ SELECT u.avatar_url, FROM team_members LEFT JOIN users u ON team_members.user_id = u.id; +-- PERFORMANCE OPTIMIZATION: Create materialized view for team member info +-- This pre-calculates the expensive joins and subqueries from team_member_info_view +CREATE MATERIALIZED VIEW IF NOT EXISTS team_member_info_mv AS +SELECT + u.avatar_url, + COALESCE(u.email, ei.email) AS email, + COALESCE(u.name, ei.name) AS name, + u.id AS user_id, + tm.id AS team_member_id, + tm.team_id, + tm.active, + u.socket_id +FROM team_members tm +LEFT JOIN users u ON tm.user_id = u.id +LEFT JOIN email_invitations ei ON ei.team_member_id = tm.id +WHERE tm.active = TRUE; + +-- Create unique index on the materialized view for fast lookups +CREATE UNIQUE INDEX IF NOT EXISTS idx_team_member_info_mv_team_member_id +ON team_member_info_mv(team_member_id); + +CREATE INDEX IF NOT EXISTS idx_team_member_info_mv_team_user +ON team_member_info_mv(team_id, user_id); + +-- Function to refresh the materialized view +CREATE OR REPLACE FUNCTION refresh_team_member_info_mv() +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY team_member_info_mv; +END; +$$; + diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 3a575bba..f5dcc666 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -69,13 +69,13 @@ export default class TasksControllerV2 extends TasksControllerBase { } private static getFilterByProjectsWhereClosure(text: string) { - return text ? `project_id IN (${this.flatString(text)})` : ""; + return text ? `t.project_id IN (${this.flatString(text)})` : ""; } private static getFilterByAssignee(filterBy: string) { return filterBy === "member" - ? `id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id = $1)` - : "project_id = $1"; + ? `t.id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id = $1)` + : "t.project_id = $1"; } private static getStatusesQuery(filterBy: string) { @@ -131,41 +131,19 @@ export default class TasksControllerV2 extends TasksControllerBase { // Returns statuses of each task as a json array if filterBy === "member" const statusesQuery = TasksControllerV2.getStatusesQuery(options.filterBy as string); - // Custom columns data query + // Custom columns data query - optimized with LEFT JOIN const customColumnsQuery = options.customColumns - ? `, (SELECT COALESCE( - jsonb_object_agg( - custom_cols.key, - custom_cols.value - ), - '{}'::JSONB - ) - FROM ( - SELECT - cc.key, - CASE - WHEN ccv.text_value IS NOT NULL THEN to_jsonb(ccv.text_value) - WHEN ccv.number_value IS NOT NULL THEN to_jsonb(ccv.number_value) - WHEN ccv.boolean_value IS NOT NULL THEN to_jsonb(ccv.boolean_value) - WHEN ccv.date_value IS NOT NULL THEN to_jsonb(ccv.date_value) - WHEN ccv.json_value IS NOT NULL THEN ccv.json_value - ELSE NULL::JSONB - END AS value - FROM cc_column_values ccv - JOIN cc_custom_columns cc ON ccv.column_id = cc.id - WHERE ccv.task_id = t.id - ) AS custom_cols - WHERE custom_cols.value IS NOT NULL) AS custom_column_values` + ? `, COALESCE(cc_data.custom_column_values, '{}'::JSONB) AS custom_column_values` : ""; - const archivedFilter = options.archived === "true" ? "archived IS TRUE" : "archived IS FALSE"; + const archivedFilter = options.archived === "true" ? "t.archived IS TRUE" : "t.archived IS FALSE"; let subTasksFilter; if (options.isSubtasksInclude === "true") { subTasksFilter = ""; } else { - subTasksFilter = isSubTasks ? "parent_task_id = $2" : "parent_task_id IS NULL"; + subTasksFilter = isSubTasks ? "t.parent_task_id = $2" : "t.parent_task_id IS NULL"; } const filters = [ @@ -179,94 +157,171 @@ export default class TasksControllerV2 extends TasksControllerBase { projectsFilter ].filter(i => !!i).join(" AND "); + // PERFORMANCE OPTIMIZED QUERY - Using CTEs and JOINs instead of correlated subqueries return ` - SELECT id, - name, - CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no) AS task_key, - (SELECT name FROM projects WHERE id = t.project_id) AS project_name, - t.project_id AS project_id, - t.parent_task_id, - t.parent_task_id IS NOT NULL AS is_sub_task, - (SELECT name FROM tasks WHERE id = t.parent_task_id) AS parent_task_name, - (SELECT COUNT(*) - FROM tasks - WHERE parent_task_id = t.id)::INT AS sub_tasks_count, - - t.status_id AS status, - t.archived, - t.description, - t.sort_order, - t.progress_value, - t.manual_progress, - t.weight, - (SELECT use_manual_progress FROM projects WHERE id = t.project_id) AS project_use_manual_progress, - (SELECT use_weighted_progress FROM projects WHERE id = t.project_id) AS project_use_weighted_progress, - (SELECT use_time_progress FROM projects WHERE id = t.project_id) AS project_use_time_progress, - (SELECT get_task_complete_ratio(t.id)->>'ratio') AS complete_ratio, - - (SELECT phase_id FROM task_phase WHERE task_id = t.id) AS phase_id, - (SELECT name - FROM project_phases - WHERE id = (SELECT phase_id FROM task_phase WHERE task_id = t.id)) AS phase_name, - (SELECT color_code - FROM project_phases - WHERE id = (SELECT phase_id FROM task_phase WHERE task_id = t.id)) AS phase_color_code, - - (EXISTS(SELECT 1 FROM task_subscribers WHERE task_id = t.id)) AS has_subscribers, - (EXISTS(SELECT 1 FROM task_dependencies td WHERE td.task_id = t.id)) AS has_dependencies, - (SELECT start_time - FROM task_timers - WHERE task_id = t.id - AND user_id = '${userId}') AS timer_start_time, - - (SELECT color_code - FROM sys_task_status_categories - WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color, - - (SELECT color_code_dark - FROM sys_task_status_categories - WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color_dark, - - (SELECT COALESCE(ROW_TO_JSON(r), '{}'::JSON) - FROM (SELECT is_done, is_doing, is_todo - FROM sys_task_status_categories - WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) r) AS status_category, - - (SELECT COUNT(*) FROM task_comments WHERE task_id = t.id) AS comments_count, - (SELECT COUNT(*) FROM task_attachments WHERE task_id = t.id) AS attachments_count, - (CASE - WHEN EXISTS(SELECT 1 - FROM tasks_with_status_view - WHERE tasks_with_status_view.task_id = t.id - AND is_done IS TRUE) THEN 1 - ELSE 0 END) AS parent_task_completed, - (SELECT get_task_assignees(t.id)) AS assignees, - (SELECT COUNT(*) - FROM tasks_with_status_view tt - WHERE tt.parent_task_id = t.id - AND tt.is_done IS TRUE)::INT - AS completed_sub_tasks, - - (SELECT COALESCE(JSON_AGG(r), '[]'::JSON) - FROM (SELECT task_labels.label_id AS id, - (SELECT name FROM team_labels WHERE id = task_labels.label_id), - (SELECT color_code FROM team_labels WHERE id = task_labels.label_id) - FROM task_labels - WHERE task_id = t.id) r) AS labels, - (SELECT is_completed(status_id, project_id)) AS is_complete, - (SELECT name FROM users WHERE id = t.reporter_id) AS reporter, - (SELECT id FROM task_priorities WHERE id = t.priority_id) AS priority, - (SELECT value FROM task_priorities WHERE id = t.priority_id) AS priority_value, - total_minutes, - (SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent, - created_at, - updated_at, - completed_at, - start_date, - billable, - schedule_id, - END_DATE ${customColumnsQuery} ${statusesQuery} + WITH task_aggregates AS ( + SELECT + t.id, + COUNT(DISTINCT sub.id) AS sub_tasks_count, + COUNT(DISTINCT CASE WHEN sub_status.is_done THEN sub.id END) AS completed_sub_tasks, + COUNT(DISTINCT tc.id) AS comments_count, + COUNT(DISTINCT ta.id) AS attachments_count, + COUNT(DISTINCT twl.id) AS work_log_count, + COALESCE(SUM(twl.time_spent), 0) AS total_minutes_spent, + MAX(CASE WHEN ts.id IS NOT NULL THEN 1 ELSE 0 END) AS has_subscribers, + MAX(CASE WHEN td.id IS NOT NULL THEN 1 ELSE 0 END) AS has_dependencies + FROM tasks t + LEFT JOIN tasks sub ON t.id = sub.parent_task_id AND sub.archived = FALSE + LEFT JOIN task_statuses sub_ts ON sub.status_id = sub_ts.id + LEFT JOIN sys_task_status_categories sub_status ON sub_ts.category_id = sub_status.id + LEFT JOIN task_comments tc ON t.id = tc.task_id + LEFT JOIN task_attachments ta ON t.id = ta.task_id + LEFT JOIN task_work_log twl ON t.id = twl.task_id + LEFT JOIN task_subscribers ts ON t.id = ts.task_id + LEFT JOIN task_dependencies td ON t.id = td.task_id + WHERE t.project_id = $1 AND t.archived = FALSE + GROUP BY t.id + ), + task_assignees AS ( + SELECT + ta.task_id, + JSON_AGG(JSON_BUILD_OBJECT( + 'team_member_id', ta.team_member_id, + 'project_member_id', ta.project_member_id, + 'name', COALESCE(tmiv.name, ''), + 'avatar_url', COALESCE(tmiv.avatar_url, ''), + 'email', COALESCE(tmiv.email, ''), + 'user_id', tmiv.user_id, + 'socket_id', COALESCE(u.socket_id, ''), + 'team_id', tmiv.team_id, + 'email_notifications_enabled', COALESCE(ns.email_notifications_enabled, false) + )) AS assignees, + STRING_AGG(COALESCE(tmiv.name, ''), ', ') AS assignee_names, + STRING_AGG(COALESCE(tmiv.name, ''), ', ') AS names + FROM tasks_assignees ta + LEFT JOIN team_member_info_view tmiv ON ta.team_member_id = tmiv.team_member_id + LEFT JOIN users u ON tmiv.user_id = u.id + LEFT JOIN notification_settings ns ON ns.user_id = u.id AND ns.team_id = tmiv.team_id + GROUP BY ta.task_id + ), + task_labels AS ( + SELECT + tl.task_id, + JSON_AGG(JSON_BUILD_OBJECT( + 'id', tl.label_id, + 'label_id', tl.label_id, + 'name', team_l.name, + 'color_code', team_l.color_code + )) AS labels, + JSON_AGG(JSON_BUILD_OBJECT( + 'id', tl.label_id, + 'label_id', tl.label_id, + 'name', team_l.name, + 'color_code', team_l.color_code + )) AS all_labels + FROM task_labels tl + JOIN team_labels team_l ON tl.label_id = team_l.id + GROUP BY tl.task_id + ) + ${options.customColumns ? `, + custom_columns_data AS ( + SELECT + ccv.task_id, + JSONB_OBJECT_AGG( + cc.key, + CASE + WHEN ccv.text_value IS NOT NULL THEN to_jsonb(ccv.text_value) + WHEN ccv.number_value IS NOT NULL THEN to_jsonb(ccv.number_value) + WHEN ccv.boolean_value IS NOT NULL THEN to_jsonb(ccv.boolean_value) + WHEN ccv.date_value IS NOT NULL THEN to_jsonb(ccv.date_value) + WHEN ccv.json_value IS NOT NULL THEN ccv.json_value + ELSE NULL::JSONB + END + ) AS custom_column_values + FROM cc_column_values ccv + JOIN cc_custom_columns cc ON ccv.column_id = cc.id + GROUP BY ccv.task_id + )` : ""} + SELECT + t.id, + t.name, + CONCAT(p.key, '-', t.task_no) AS task_key, + p.name AS project_name, + t.project_id, + t.parent_task_id, + t.parent_task_id IS NOT NULL AS is_sub_task, + parent_task.name AS parent_task_name, + t.status_id AS status, + t.archived, + t.description, + t.sort_order, + t.progress_value, + t.manual_progress, + t.weight, + p.use_manual_progress AS project_use_manual_progress, + p.use_weighted_progress AS project_use_weighted_progress, + p.use_time_progress AS project_use_time_progress, + -- Use stored progress value instead of expensive function call + COALESCE(t.progress_value, 0) AS complete_ratio, + -- Phase information via JOINs + tp.phase_id, + pp.name AS phase_name, + pp.color_code AS phase_color_code, + -- Status information via JOINs + stsc.color_code AS status_color, + stsc.color_code_dark AS status_color_dark, + JSON_BUILD_OBJECT( + 'is_done', stsc.is_done, + 'is_doing', stsc.is_doing, + 'is_todo', stsc.is_todo + ) AS status_category, + -- Aggregated counts + COALESCE(agg.sub_tasks_count, 0) AS sub_tasks_count, + COALESCE(agg.completed_sub_tasks, 0) AS completed_sub_tasks, + COALESCE(agg.comments_count, 0) AS comments_count, + COALESCE(agg.attachments_count, 0) AS attachments_count, + COALESCE(agg.total_minutes_spent, 0) AS total_minutes_spent, + CASE WHEN agg.has_subscribers > 0 THEN true ELSE false END AS has_subscribers, + CASE WHEN agg.has_dependencies > 0 THEN true ELSE false END AS has_dependencies, + -- Task completion status + CASE WHEN stsc.is_done THEN 1 ELSE 0 END AS parent_task_completed, + -- Assignees and labels via JOINs + COALESCE(assignees.assignees, '[]'::JSON) AS assignees, + COALESCE(assignees.assignee_names, '') AS assignee_names, + COALESCE(assignees.names, '') AS names, + COALESCE(labels.labels, '[]'::JSON) AS labels, + COALESCE(labels.all_labels, '[]'::JSON) AS all_labels, + -- Other fields + stsc.is_done AS is_complete, + reporter.name AS reporter, + t.priority_id AS priority, + tp_priority.value AS priority_value, + t.total_minutes, + t.created_at, + t.updated_at, + t.completed_at, + t.start_date, + t.billable, + t.schedule_id, + t.END_DATE, + -- Timer information + tt.start_time AS timer_start_time + ${customColumnsQuery} + ${statusesQuery} FROM tasks t + JOIN projects p ON t.project_id = p.id + JOIN task_statuses ts ON t.status_id = ts.id + JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + LEFT JOIN tasks parent_task ON t.parent_task_id = parent_task.id + LEFT JOIN task_phase tp ON t.id = tp.task_id + LEFT JOIN project_phases pp ON tp.phase_id = pp.id + LEFT JOIN task_priorities tp_priority ON t.priority_id = tp_priority.id + LEFT JOIN users reporter ON t.reporter_id = reporter.id + LEFT JOIN task_timers tt ON t.id = tt.task_id AND tt.user_id = $${isSubTasks ? "3" : "2"} + LEFT JOIN task_aggregates agg ON t.id = agg.id + LEFT JOIN task_assignees assignees ON t.id = assignees.task_id + LEFT JOIN task_labels labels ON t.id = labels.task_id + ${options.customColumns ? "LEFT JOIN custom_columns_data cc_data ON t.id = cc_data.task_id" : ""} WHERE ${filters} ${searchQuery} ORDER BY ${sortFields} `; @@ -347,7 +402,7 @@ export default class TasksControllerV2 extends TasksControllerBase { req.query.customColumns = "true"; const q = TasksControllerV2.getQuery(req.user?.id as string, req.query); - const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; + const params = isSubTasks ? [req.params.id || null, req.query.parent_task, req.user?.id] : [req.params.id || null, req.user?.id]; const result = await db.query(q, params); const tasks = [...result.rows]; @@ -455,7 +510,7 @@ export default class TasksControllerV2 extends TasksControllerBase { req.query.customColumns = "true"; const q = TasksControllerV2.getQuery(req.user?.id as string, req.query); - const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; + const params = isSubTasks ? [req.params.id || null, req.query.parent_task, req.user?.id] : [req.params.id || null, req.user?.id]; const result = await db.query(q, params); let data: any[] = []; @@ -986,60 +1041,62 @@ export default class TasksControllerV2 extends TasksControllerBase { @HandleExceptions() public static async getTasksV3(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const startTime = performance.now(); - const isSubTasks = !!req.query.parent_task; - const groupBy = (req.query.group || GroupBy.STATUS) as string; - const archived = req.query.archived === "true"; - + console.log(`[PERFORMANCE] getTasksV3 method called for project ${req.params.id}`); + // PERFORMANCE OPTIMIZATION: Skip expensive progress calculation by default // Progress values are already calculated and stored in the database // Only refresh if explicitly requested via refresh_progress=true query parameter - // This dramatically improves initial load performance (from ~2-5s to ~200-500ms) - const shouldRefreshProgress = req.query.refresh_progress === "true"; - - if (shouldRefreshProgress && req.params.id) { + if (req.query.refresh_progress === "true" && req.params.id) { + console.log(`[PERFORMANCE] Starting progress refresh for project ${req.params.id} (getTasksV3)`); const progressStartTime = performance.now(); await this.refreshProjectTaskProgressValues(req.params.id); const progressEndTime = performance.now(); + console.log(`[PERFORMANCE] Progress refresh completed in ${(progressEndTime - progressStartTime).toFixed(2)}ms`); } - const queryStartTime = performance.now(); + const isSubTasks = !!req.query.parent_task; + const groupBy = (req.query.group || GroupBy.STATUS) as string; + + // Add customColumns flag to query params (same as getList) + req.query.customColumns = "true"; + + // Use the exact same database query as getList method const q = TasksControllerV2.getQuery(req.user?.id as string, req.query); - const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; + const params = isSubTasks ? [req.params.id || null, req.query.parent_task, req.user?.id] : [req.params.id || null, req.user?.id]; const result = await db.query(q, params); const tasks = [...result.rows]; - const queryEndTime = performance.now(); - // Get groups metadata dynamically from database - const groupsStartTime = performance.now(); + // Use the same groups query as getList method const groups = await this.getGroups(groupBy, req.params.id); - const groupsEndTime = performance.now(); + const map = groups.reduce((g: { [x: string]: ITaskGroup }, group) => { + if (group.id) + g[group.id] = new TaskListGroup(group); + return g; + }, {}); - // Create priority value to name mapping + // Use the same updateMapByGroup method as getList + await this.updateMapByGroup(tasks, groupBy, map); + + // Calculate progress for groups (same as getList) + const updatedGroups = Object.keys(map).map(key => { + const group = map[key]; + TasksControllerV2.updateTaskProgresses(group); + return { + id: key, + ...group + }; + }); + + // Transform to V3 response format while maintaining the same data processing const priorityMap: Record = { "0": "low", "1": "medium", "2": "high" }; - // Create status category mapping based on actual status names from database - const statusCategoryMap: Record = {}; - for (const group of groups) { - if (groupBy === GroupBy.STATUS && group.id) { - // Use the actual status name from database, convert to lowercase for consistency - statusCategoryMap[group.id] = group.name.toLowerCase().replace(/\s+/g, "_"); - } - } - - - - // Transform tasks with all necessary data preprocessing - const transformStartTime = performance.now(); + // Transform all tasks to V3 format const transformedTasks = tasks.map((task, index) => { - // Update task with calculated values (lightweight version) - TasksControllerV2.updateTaskViewModel(task); - task.index = index; - // Convert time values const convertTimeValue = (value: any): number => { if (typeof value === "number") return value; @@ -1062,15 +1119,12 @@ export default class TasksControllerV2 extends TasksControllerBase { task_key: task.task_key || "", title: task.name || "", description: task.description || "", - // Use dynamic status mapping from database - status: statusCategoryMap[task.status] || task.status, - // Pre-processed priority using mapping + status: task.status || "todo", priority: priorityMap[task.priority_value?.toString()] || "medium", - // Use actual phase name from database phase: task.phase_name || "Development", progress: typeof task.complete_ratio === "number" ? task.complete_ratio : 0, assignees: task.assignees?.map((a: any) => a.team_member_id) || [], - assignee_names: task.assignee_names || task.names || [], + assignee_names: task.assignees || [], labels: task.labels?.map((l: any) => ({ id: l.id || l.label_id, name: l.name, @@ -1090,7 +1144,7 @@ export default class TasksControllerV2 extends TasksControllerBase { logged: convertTimeValue(task.time_spent), }, customFields: {}, - custom_column_values: task.custom_column_values || {}, // Include custom column values + custom_column_values: task.custom_column_values || {}, createdAt: task.created_at || new Date().toISOString(), updatedAt: task.updated_at || new Date().toISOString(), order: typeof task.sort_order === "number" ? task.sort_order : 0, @@ -1109,124 +1163,53 @@ export default class TasksControllerV2 extends TasksControllerBase { schedule_id: task.schedule_id || null, }; }); - const transformEndTime = performance.now(); - // Create groups based on dynamic data from database - const groupingStartTime = performance.now(); - const groupedResponse: Record = {}; - - // Initialize groups from database data - groups.forEach(group => { - const groupKey = groupBy === GroupBy.STATUS - ? group.name.toLowerCase().replace(/\s+/g, "_") - : groupBy === GroupBy.PRIORITY - ? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase() - : group.name.toLowerCase().replace(/\s+/g, "_"); - - groupedResponse[groupKey] = { - id: group.id, - title: group.name, - groupType: groupBy, - groupValue: groupKey, - collapsed: false, - tasks: [], - taskIds: [], - color: group.color_code || this.getDefaultGroupColor(groupBy, groupKey), - // Include additional metadata from database - category_id: group.category_id, - start_date: group.start_date, - end_date: group.end_date, - sort_index: (group as any).sort_index, - }; - }); + // Transform groups to V3 format while preserving the getList logic + const responseGroups = updatedGroups.map(group => { + // Create status category mapping for consistent group naming + let groupValue = group.name; + if (groupBy === GroupBy.STATUS) { + groupValue = group.name.toLowerCase().replace(/\s+/g, "_"); + } else if (groupBy === GroupBy.PRIORITY) { + groupValue = group.name.toLowerCase(); + } else if (groupBy === GroupBy.PHASE) { + groupValue = group.name.toLowerCase().replace(/\s+/g, "_"); + } - // Distribute tasks into groups - const unmappedTasks: any[] = []; - - transformedTasks.forEach(task => { - let groupKey: string; - let taskAssigned = false; - - if (groupBy === GroupBy.STATUS) { - groupKey = task.status; - if (groupedResponse[groupKey]) { - groupedResponse[groupKey].tasks.push(task); - groupedResponse[groupKey].taskIds.push(task.id); - taskAssigned = true; - } - } else if (groupBy === GroupBy.PRIORITY) { - groupKey = task.priority; - if (groupedResponse[groupKey]) { - groupedResponse[groupKey].tasks.push(task); - groupedResponse[groupKey].taskIds.push(task.id); - taskAssigned = true; - } - } else if (groupBy === GroupBy.PHASE) { - // For phase grouping, check if task has a valid phase - if (task.phase && task.phase.trim() !== "") { - groupKey = task.phase.toLowerCase().replace(/\s+/g, "_"); - if (groupedResponse[groupKey]) { - groupedResponse[groupKey].tasks.push(task); - groupedResponse[groupKey].taskIds.push(task.id); - taskAssigned = true; - } - } - // If task doesn't have a valid phase, add to unmapped - if (!taskAssigned) { - unmappedTasks.push(task); - } - } - }); + // Transform tasks in this group to V3 format + const groupTasks = group.tasks.map(task => { + const foundTask = transformedTasks.find(t => t.id === task.id); + return foundTask || task; + }); - // Create unmapped group if there are tasks without proper phase assignment - if (unmappedTasks.length > 0 && groupBy === GroupBy.PHASE) { - groupedResponse[UNMAPPED.toLowerCase()] = { - id: UNMAPPED, - title: UNMAPPED, - groupType: groupBy, - groupValue: UNMAPPED.toLowerCase(), - collapsed: false, - tasks: unmappedTasks, - taskIds: unmappedTasks.map(task => task.id), - color: "#fbc84c69", // Orange color with transparency - category_id: null, - start_date: null, - end_date: null, - sort_index: 999, // Put unmapped group at the end - }; - } - - // Sort tasks within each group by order - Object.values(groupedResponse).forEach((group: any) => { - group.tasks.sort((a: any, b: any) => a.order - b.order); - }); - - // Convert to array format expected by frontend, maintaining database order - const responseGroups = groups - .map(group => { - const groupKey = groupBy === GroupBy.STATUS - ? group.name.toLowerCase().replace(/\s+/g, "_") - : groupBy === GroupBy.PRIORITY - ? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase() - : group.name.toLowerCase().replace(/\s+/g, "_"); - - return groupedResponse[groupKey]; - }) - .filter(group => group && (group.tasks.length > 0 || req.query.include_empty === "true")); - - // Add unmapped group to the end if it exists - if (groupedResponse[UNMAPPED.toLowerCase()]) { - responseGroups.push(groupedResponse[UNMAPPED.toLowerCase()]); - } - - const groupingEndTime = performance.now(); + return { + id: group.id, + title: group.name, + groupType: groupBy, + groupValue, + collapsed: false, + tasks: groupTasks, + taskIds: groupTasks.map((task: any) => task.id), + color: group.color_code || this.getDefaultGroupColor(groupBy, groupValue), + // Include additional metadata from database + category_id: group.category_id, + start_date: group.start_date, + end_date: group.end_date, + sort_index: (group as any).sort_index, + // Include progress information from getList logic + todo_progress: group.todo_progress, + doing_progress: group.doing_progress, + done_progress: group.done_progress, + }; + }).filter(group => group.tasks.length > 0 || req.query.include_empty === "true"); const endTime = performance.now(); const totalTime = endTime - startTime; + console.log(`[PERFORMANCE] getTasksV3 method completed in ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks`); - // Log warning if request is taking too long + // Log warning if this method is taking too long if (totalTime > 1000) { - console.warn(`[PERFORMANCE WARNING] Slow request detected: ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks`); + console.warn(`[PERFORMANCE WARNING] getTasksV3 method taking ${totalTime.toFixed(2)}ms - Consider optimizing the query or data processing!`); } return res.status(200).send(new ServerResponse(true, { @@ -1237,6 +1220,315 @@ export default class TasksControllerV2 extends TasksControllerBase { })); } + /** + * NEW OPTIMIZED METHOD: Split complex query into focused segments for better performance + */ + @HandleExceptions() + public static async getTasksV4Optimized(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const startTime = performance.now(); + console.log(`[PERFORMANCE] getTasksV4Optimized method called for project ${req.params.id}`); + + // Skip progress refresh by default for better performance + if (req.query.refresh_progress === "true" && req.params.id) { + const progressStartTime = performance.now(); + await this.refreshProjectTaskProgressValues(req.params.id); + const progressEndTime = performance.now(); + console.log(`[PERFORMANCE] Progress refresh completed in ${(progressEndTime - progressStartTime).toFixed(2)}ms`); + } + + const isSubTasks = !!req.query.parent_task; + const groupBy = (req.query.group || GroupBy.STATUS) as string; + const projectId = req.params.id; + const userId = req.user?.id; + + // STEP 1: Get basic task data with optimized query + const baseTasksQuery = ` + SELECT + t.id, + t.name, + CONCAT(p.key, '-', t.task_no) AS task_key, + p.name AS project_name, + t.project_id, + t.parent_task_id, + t.parent_task_id IS NOT NULL AS is_sub_task, + t.status_id AS status, + t.priority_id AS priority, + t.description, + t.sort_order, + t.progress_value AS complete_ratio, + t.manual_progress, + t.weight, + t.start_date, + t.end_date, + t.created_at, + t.updated_at, + t.completed_at, + t.billable, + t.schedule_id, + t.total_minutes, + -- Status information via JOINs + stsc.color_code AS status_color, + stsc.color_code_dark AS status_color_dark, + stsc.is_done, + stsc.is_doing, + stsc.is_todo, + -- Priority information + tp_priority.value AS priority_value, + -- Phase information + tp.phase_id, + pp.name AS phase_name, + pp.color_code AS phase_color_code, + -- Reporter information + reporter.name AS reporter, + -- Timer information + tt.start_time AS timer_start_time + FROM tasks t + JOIN projects p ON t.project_id = p.id + JOIN task_statuses ts ON t.status_id = ts.id + JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + LEFT JOIN task_phase tp ON t.id = tp.task_id + LEFT JOIN project_phases pp ON tp.phase_id = pp.id + LEFT JOIN task_priorities tp_priority ON t.priority_id = tp_priority.id + LEFT JOIN users reporter ON t.reporter_id = reporter.id + LEFT JOIN task_timers tt ON t.id = tt.task_id AND tt.user_id = $2 + WHERE t.project_id = $1 + AND t.archived = FALSE + ${isSubTasks ? "AND t.parent_task_id = $3" : "AND t.parent_task_id IS NULL"} + ORDER BY t.sort_order + `; + + const baseParams = isSubTasks ? [projectId, userId, req.query.parent_task] : [projectId, userId]; + const baseResult = await db.query(baseTasksQuery, baseParams); + const baseTasks = baseResult.rows; + + if (baseTasks.length === 0) { + return res.status(200).send(new ServerResponse(true, { + groups: [], + allTasks: [], + grouping: groupBy, + totalTasks: 0 + })); + } + + const taskIds = baseTasks.map(t => t.id); + + // STEP 2: Get aggregated data in parallel + const [assigneesResult, labelsResult, aggregatesResult] = await Promise.all([ + // Get assignees + db.query(` + SELECT + ta.task_id, + JSON_AGG(JSON_BUILD_OBJECT( + 'team_member_id', ta.team_member_id, + 'project_member_id', ta.project_member_id, + 'name', COALESCE(tm.name, ''), + 'avatar_url', COALESCE(u.avatar_url, ''), + 'email', COALESCE(u.email, ei.email, ''), + 'user_id', tm.user_id, + 'socket_id', COALESCE(u.socket_id, ''), + 'team_id', tm.team_id + )) AS assignees + FROM tasks_assignees ta + LEFT JOIN team_members tm ON ta.team_member_id = tm.id + LEFT JOIN users u ON tm.user_id = u.id + LEFT JOIN email_invitations ei ON ta.team_member_id = ei.team_member_id + WHERE ta.task_id = ANY($1) + GROUP BY ta.task_id + `, [taskIds]), + + // Get labels + db.query(` + SELECT + tl.task_id, + JSON_AGG(JSON_BUILD_OBJECT( + 'id', tl.label_id, + 'label_id', tl.label_id, + 'name', team_l.name, + 'color_code', team_l.color_code + )) AS labels + FROM task_labels tl + JOIN team_labels team_l ON tl.label_id = team_l.id + WHERE tl.task_id = ANY($1) + GROUP BY tl.task_id + `, [taskIds]), + + // Get aggregated counts + db.query(` + SELECT + t.id, + COUNT(DISTINCT sub.id) AS sub_tasks_count, + COUNT(DISTINCT CASE WHEN sub_status.is_done THEN sub.id END) AS completed_sub_tasks, + COUNT(DISTINCT tc.id) AS comments_count, + COUNT(DISTINCT ta.id) AS attachments_count, + COALESCE(SUM(twl.time_spent), 0) AS total_minutes_spent, + CASE WHEN COUNT(ts.id) > 0 THEN true ELSE false END AS has_subscribers, + CASE WHEN COUNT(td.id) > 0 THEN true ELSE false END AS has_dependencies + FROM unnest($1::uuid[]) AS t(id) + LEFT JOIN tasks sub ON t.id = sub.parent_task_id AND sub.archived = FALSE + LEFT JOIN task_statuses sub_ts ON sub.status_id = sub_ts.id + LEFT JOIN sys_task_status_categories sub_status ON sub_ts.category_id = sub_status.id + LEFT JOIN task_comments tc ON t.id = tc.task_id + LEFT JOIN task_attachments ta ON t.id = ta.task_id + LEFT JOIN task_work_log twl ON t.id = twl.task_id + LEFT JOIN task_subscribers ts ON t.id = ts.task_id + LEFT JOIN task_dependencies td ON t.id = td.task_id + GROUP BY t.id + `, [taskIds]) + ]); + + // STEP 3: Create lookup maps for efficient data merging + const assigneesMap = new Map(); + assigneesResult.rows.forEach(row => assigneesMap.set(row.task_id, row.assignees || [])); + + const labelsMap = new Map(); + labelsResult.rows.forEach(row => labelsMap.set(row.task_id, row.labels || [])); + + const aggregatesMap = new Map(); + aggregatesResult.rows.forEach(row => aggregatesMap.set(row.id, row)); + + // STEP 4: Merge data efficiently + const enrichedTasks = baseTasks.map(task => { + const aggregates = aggregatesMap.get(task.id) || {}; + const assignees = assigneesMap.get(task.id) || []; + const labels = labelsMap.get(task.id) || []; + + return { + ...task, + assignees, + assignee_names: assignees.map((a: any) => a.name).join(", "), + names: assignees.map((a: any) => a.name).join(", "), + labels, + all_labels: labels, + sub_tasks_count: parseInt(aggregates.sub_tasks_count || 0), + completed_sub_tasks: parseInt(aggregates.completed_sub_tasks || 0), + comments_count: parseInt(aggregates.comments_count || 0), + attachments_count: parseInt(aggregates.attachments_count || 0), + total_minutes_spent: parseFloat(aggregates.total_minutes_spent || 0), + has_subscribers: aggregates.has_subscribers || false, + has_dependencies: aggregates.has_dependencies || false, + status_category: { + is_done: task.is_done, + is_doing: task.is_doing, + is_todo: task.is_todo + } + }; + }); + + // STEP 5: Group tasks (same logic as existing method) + const groups = await this.getGroups(groupBy, req.params.id); + const map = groups.reduce((g: { [x: string]: ITaskGroup }, group) => { + if (group.id) + g[group.id] = new TaskListGroup(group); + return g; + }, {}); + + await this.updateMapByGroup(enrichedTasks, groupBy, map); + + const updatedGroups = Object.keys(map).map(key => { + const group = map[key]; + TasksControllerV2.updateTaskProgresses(group); + return { + id: key, + ...group + }; + }); + + // STEP 6: Transform to V3 format (same as existing method) + const priorityMap: Record = { + "0": "low", + "1": "medium", + "2": "high" + }; + + const transformedTasks = enrichedTasks.map(task => ({ + id: task.id, + task_key: task.task_key || "", + title: task.name || "", + description: task.description || "", + status: task.status || "todo", + priority: priorityMap[task.priority_value?.toString()] || "medium", + phase: task.phase_name || "Development", + progress: typeof task.complete_ratio === "number" ? task.complete_ratio : 0, + assignees: task.assignees?.map((a: any) => a.team_member_id) || [], + assignee_names: task.assignees || [], + labels: task.labels?.map((l: any) => ({ + id: l.id || l.label_id, + name: l.name, + color: l.color_code || "#1890ff" + })) || [], + dueDate: task.end_date, + startDate: task.start_date, + timeTracking: { + estimated: task.total_minutes || 0, + logged: task.total_minutes_spent || 0, + }, + customFields: {}, + createdAt: task.created_at || new Date().toISOString(), + updatedAt: task.updated_at || new Date().toISOString(), + order: typeof task.sort_order === "number" ? task.sort_order : 0, + originalStatusId: task.status, + originalPriorityId: task.priority, + statusColor: task.status_color, + priorityColor: task.priority_color, + sub_tasks_count: task.sub_tasks_count || 0, + comments_count: task.comments_count || 0, + has_subscribers: !!task.has_subscribers, + attachments_count: task.attachments_count || 0, + has_dependencies: !!task.has_dependencies, + schedule_id: task.schedule_id || null, + })); + + const responseGroups = updatedGroups.map(group => { + let groupValue = group.name; + if (groupBy === GroupBy.STATUS) { + groupValue = group.name.toLowerCase().replace(/\s+/g, "_"); + } else if (groupBy === GroupBy.PRIORITY) { + groupValue = group.name.toLowerCase(); + } else if (groupBy === GroupBy.PHASE) { + groupValue = group.name.toLowerCase().replace(/\s+/g, "_"); + } + + const groupTasks = group.tasks.map(task => { + const foundTask = transformedTasks.find(t => t.id === task.id); + return foundTask || task; + }); + + return { + id: group.id, + title: group.name, + groupType: groupBy, + groupValue, + collapsed: false, + tasks: groupTasks, + taskIds: groupTasks.map((task: any) => task.id), + color: group.color_code || this.getDefaultGroupColor(groupBy, groupValue), + category_id: group.category_id, + start_date: group.start_date, + end_date: group.end_date, + sort_index: (group as any).sort_index, + todo_progress: group.todo_progress, + doing_progress: group.doing_progress, + done_progress: group.done_progress, + }; + }).filter(group => group.tasks.length > 0 || req.query.include_empty === "true"); + + const endTime = performance.now(); + const totalTime = endTime - startTime; + console.log(`[PERFORMANCE] getTasksV4Optimized method completed in ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks - Improvement: ${2136 - totalTime > 0 ? "+" : ""}${(2136 - totalTime).toFixed(2)}ms`); + + return res.status(200).send(new ServerResponse(true, { + groups: responseGroups, + allTasks: transformedTasks, + grouping: groupBy, + totalTasks: transformedTasks.length, + performanceMetrics: { + executionTime: Math.round(totalTime), + tasksCount: transformedTasks.length, + optimizationGain: Math.round(2136 - totalTime) + } + })); + } + private static getDefaultGroupColor(groupBy: string, groupValue: string): string { const colorMaps: Record> = { [GroupBy.STATUS]: { @@ -1332,4 +1624,6 @@ export default class TasksControllerV2 extends TasksControllerBase { return res.status(500).send(new ServerResponse(false, null, "Failed to get task progress status")); } } + + } diff --git a/worklenz-backend/worklenz-email-templates/release-note-template.html b/worklenz-backend/worklenz-email-templates/release-note-template.html index 592917bf..4f0e2a45 100644 --- a/worklenz-backend/worklenz-email-templates/release-note-template.html +++ b/worklenz-backend/worklenz-email-templates/release-note-template.html @@ -2,31 +2,35 @@ - + Worklenz 2.1.0 Release + - - - - - - - - -
-

- Click here to unsubscribe and manage your email preferences. -

+ + + + + +
+ + + + + + + + + + +
+ + Worklenz Light Logo + + +
+
+

🚀 New Tasks List & Kanban Board

+
    +
  • Performance optimized for faster loading
  • +
  • Redesigned UI for clarity and speed
  • +
  • Advanced filters for easier task management
  • +
+ New Task List + New Kanban Board +
+
+

📁 Group View in Projects List

+
    +
  • Toggle between list and group view
  • +
  • Group projects by client or category
  • +
  • Improved navigation and organization
  • +
+ Project List Group View +
+
+

🌐 New Language Support

+ Deutsch (DE) + Shqip (ALB) +

Worklenz is now available in German and Albanian!

+
+
+

🛠️ Bug Fixes & UI Improvements

+
    +
  • General bug fixes
  • +
  • UI/UX enhancements for a smoother experience
  • +
  • Performance improvements across the platform
  • +
+
+ +
+
+

+ Click here to unsubscribe and + manage your email preferences. +

+
+
- + \ No newline at end of file diff --git a/worklenz-frontend/index.html b/worklenz-frontend/index.html index a2f637b2..faeccff7 100644 --- a/worklenz-frontend/index.html +++ b/worklenz-frontend/index.html @@ -5,43 +5,72 @@ + + + + + + + + + + + + + Worklenz + - + + diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json index 5e5154f3..f12aaee4 100644 --- a/worklenz-frontend/package-lock.json +++ b/worklenz-frontend/package-lock.json @@ -722,6 +722,27 @@ "react": ">=16.9.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1014,6 +1035,121 @@ "react": ">=16.12.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@ctrl/tinycolor": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", @@ -2855,6 +2991,16 @@ "object-assign": "4.x" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3586,12 +3732,77 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -3625,6 +3836,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3792,6 +4010,19 @@ "node": ">=10.0.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -4262,6 +4493,19 @@ "react-is": "^16.7.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -4284,6 +4528,34 @@ "node": ">=8.0.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/hyphenate-style-name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", @@ -4331,6 +4603,19 @@ "cross-fetch": "4.0.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/immer": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", @@ -4439,6 +4724,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "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" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4520,6 +4812,105 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5137,6 +5528,13 @@ "node": ">=0.10.0" } }, + "node_modules/nwsapi": { + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5191,6 +5589,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5643,6 +6054,16 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6779,6 +7200,13 @@ "rrweb-snapshot": "^2.0.0-alpha.18" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/rrweb-snapshot": { "version": "2.0.0-alpha.18", "resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.18.tgz", @@ -6820,6 +7248,26 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -7210,6 +7658,13 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "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" + }, "node_modules/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", @@ -7422,6 +7877,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7440,6 +7915,19 @@ "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", "license": "MIT" }, + "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", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -7790,6 +8278,19 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", @@ -7811,6 +8312,29 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -7957,6 +8481,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", diff --git a/worklenz-frontend/public/locales/alb/project-view.json b/worklenz-frontend/public/locales/alb/project-view.json new file mode 100644 index 00000000..2bc256fe --- /dev/null +++ b/worklenz-frontend/public/locales/alb/project-view.json @@ -0,0 +1,14 @@ +{ + "taskList": "Lista e Detyrave", + "board": "Tabela Kanban", + "insights": "Analiza", + "files": "Skedarë", + "members": "Anëtarë", + "updates": "Përditësime", + "projectView": "Pamja e Projektit", + "loading": "Duke ngarkuar projektin...", + "error": "Gabim në ngarkimin e projektit", + "pinnedTab": "E fiksuar si tab i parazgjedhur", + "pinTab": "Fikso si tab i parazgjedhur", + "unpinTab": "Hiqe fiksimin e tab-it të parazgjedhur" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/alb/project-view/project-view-header.json b/worklenz-frontend/public/locales/alb/project-view/project-view-header.json index 3335738f..f12bdd8d 100644 --- a/worklenz-frontend/public/locales/alb/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/alb/project-view/project-view-header.json @@ -4,14 +4,26 @@ "createTask": "Krijo detyrë", "settings": "Cilësimet", "subscribe": "Abonohu", - "unsubscribe": "Ç'abonohu", + "unsubscribe": "Çabonohu", "deleteProject": "Fshi projektin", "startDate": "Data e fillimit", - "endDate": "Data e përfundimit", + "endDate": "Data e mbarimit", "projectSettings": "Cilësimet e projektit", "projectSummary": "Përmbledhja e projektit", "receiveProjectSummary": "Merrni një përmbledhje të projektit çdo mbrëmje.", "refreshProject": "Rifresko projektin", - "saveAsTemplate": "Ruaje si shabllon", - "invite": "Fto" + "saveAsTemplate": "Ruaj si model", + "invite": "Fto", + "subscribeTooltip": "Abonohu tek njoftimet e projektit", + "unsubscribeTooltip": "Çabonohu nga njoftimet e projektit", + "refreshTooltip": "Rifresko të dhënat e projektit", + "settingsTooltip": "Hap cilësimet e projektit", + "saveAsTemplateTooltip": "Ruaj këtë projekt si model", + "inviteTooltip": "Fto anëtarë të ekipit në këtë projekt", + "createTaskTooltip": "Krijo një detyrë të re", + "importTaskTooltip": "Importo detyrë nga modeli", + "navigateBackTooltip": "Kthehu tek lista e projekteve", + "projectStatusTooltip": "Statusi i projektit", + "projectDatesInfo": "Informacion për kohëzgjatjen e projektit", + "projectCategoryTooltip": "Kategoria e projektit" } diff --git a/worklenz-frontend/public/locales/alb/settings/profile.json b/worklenz-frontend/public/locales/alb/settings/profile.json index c3ad210d..dcce50d5 100644 --- a/worklenz-frontend/public/locales/alb/settings/profile.json +++ b/worklenz-frontend/public/locales/alb/settings/profile.json @@ -9,5 +9,6 @@ "saveChanges": "Ruaj Ndryshimet", "profileJoinedText": "U bashkua një muaj më parë", "profileLastUpdatedText": "Përditësuar një muaj më parë", - "avatarTooltip": "Klikoni për të ngarkuar një avatar" + "avatarTooltip": "Klikoni për të ngarkuar një avatar", + "title": "Cilësimet e Profilit" } diff --git a/worklenz-frontend/public/locales/alb/settings/team-members.json b/worklenz-frontend/public/locales/alb/settings/team-members.json index 0ebdb3b5..955954dc 100644 --- a/worklenz-frontend/public/locales/alb/settings/team-members.json +++ b/worklenz-frontend/public/locales/alb/settings/team-members.json @@ -1,4 +1,5 @@ { + "title": "Anëtarët e Ekipit", "nameColumn": "Emri", "projectsColumn": "Projektet", "emailColumn": "Email", @@ -40,5 +41,7 @@ "ownerText": "Pronar i Ekipit", "addedText": "Shtuar", "updatedText": "Përditësuar", - "noResultFound": "Shkruani një adresë email dhe shtypni Enter..." + "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!" } diff --git a/worklenz-frontend/public/locales/alb/settings/teams.json b/worklenz-frontend/public/locales/alb/settings/teams.json new file mode 100644 index 00000000..30f87d79 --- /dev/null +++ b/worklenz-frontend/public/locales/alb/settings/teams.json @@ -0,0 +1,16 @@ +{ + "title": "Ekipet", + "team": "Ekip", + "teams": "Ekipet", + "name": "Emri", + "created": "Krijuar", + "ownsBy": "I përket", + "edit": "Ndrysho", + "editTeam": "Ndrysho Ekipin", + "pinTooltip": "Kliko për ta fiksuar në menunë kryesore", + "editTeamName": "Ndrysho Emrin e Ekipit", + "updateName": "Përditëso Emrin", + "namePlaceholder": "Emri", + "nameRequired": "Ju lutem shkruani një Emër", + "updateFailed": "Ndryshimi i emrit të ekipit dështoi!" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/alb/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/alb/task-drawer/task-drawer.json index d2f7ef99..df525752 100644 --- a/worklenz-frontend/public/locales/alb/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/alb/task-drawer/task-drawer.json @@ -1,28 +1,37 @@ { "taskHeader": { - "taskNamePlaceholder": "Shkruani detyrën tuaj", + "taskNamePlaceholder": "Shkruani Detyrën tuaj", "deleteTask": "Fshi Detyrën" }, "taskInfoTab": { - "title": "Info", + "title": "Informacioni", "details": { "title": "Detajet", "task-key": "Çelësi i Detyrës", "phase": "Faza", - "assignees": "Përgjegjësit", - "due-date": "Afati i Përfundimit", + "assignees": "Të Caktuar", + "due-date": "Data e Përfundimit", "time-estimation": "Vlerësimi i Kohës", "priority": "Prioriteti", - "labels": "Etiketa", - "billable": "Fakturueshme", + "labels": "Etiketat", + "billable": "E Faturueshme", "notify": "Njofto", - "when-done-notify": "Kur të përfundojë, njofto", + "when-done-notify": "Kur përfundon, njofto", "start-date": "Data e Fillimit", "end-date": "Data e Përfundimit", "hide-start-date": "Fshih Datën e Fillimit", "show-start-date": "Shfaq Datën e Fillimit", "hours": "Orë", - "minutes": "Minuta" + "minutes": "Minuta", + "progressValue": "Vlera e Progresit", + "progressValueTooltip": "Vendosni përqindjen e progresit (0-100%)", + "progressValueRequired": "Ju lutemi vendosni një vlerë progresi", + "progressValueRange": "Progresi duhet të jetë midis 0 dhe 100", + "taskWeight": "Pesha e Detyrës", + "taskWeightTooltip": "Vendosni peshën e kësaj nëndetyre (përqindje)", + "taskWeightRequired": "Ju lutemi vendosni një peshë detyre", + "taskWeightRange": "Pesha duhet të jetë midis 0 dhe 100", + "recurring": "E Përsëritur" }, "labels": { "labelInputPlaceholder": "Kërko ose krijo", @@ -30,37 +39,48 @@ }, "description": { "title": "Përshkrimi", - "placeholder": "Shtoni një përshkrim më të detajuar..." + "placeholder": "Shto një përshkrim më të detajuar..." }, "subTasks": { - "title": "Nën-Detyrat", - "addSubTask": "+ Shto Nën-Detyrë", - "addSubTaskInputPlaceholder": "Shkruani detyrën dhe shtypni Enter", - "refreshSubTasks": "Rifresko Nën-Detyrat", + "title": "Nëndetyrat", + "addSubTask": "Shto Nëndetyrë", + "addSubTaskInputPlaceholder": "Shkruani detyrën tuaj dhe shtypni enter", + "refreshSubTasks": "Rifresko Nëndetyrat", "edit": "Modifiko", "delete": "Fshi", - "confirmDeleteSubTask": "Jeni i sigurt që doni të fshini këtë nën-detyrë?", - "deleteSubTask": "Fshi Nën-Detyrën" + "confirmDeleteSubTask": "Jeni i sigurt që doni të fshini këtë nëndetyrë?", + "deleteSubTask": "Fshi Nëndetyrën" }, "dependencies": { "title": "Varësitë", "addDependency": "+ Shto varësi të re", - "blockedBy": "I bllokuar nga", - "searchTask": "Shkruani për të kërkuar detyra", - "noTasksFound": "Asnjë detyrë nuk u gjet", + "blockedBy": "Bllokuar nga", + "searchTask": "Shkruani për të kërkuar detyrë", + "noTasksFound": "Nuk u gjetën detyra", "confirmDeleteDependency": "Jeni i sigurt që doni të fshini?" }, "attachments": { "title": "Bashkëngjitjet", - "chooseOrDropFileToUpload": "Zgjidhni ose lëshoni skedar për ngarkim", - "uploading": "Po ngarkohet..." + "chooseOrDropFileToUpload": "Zgjidhni ose hidhni skedar për të ngarkuar", + "uploading": "Duke ngarkuar..." }, "comments": { "title": "Komentet", "addComment": "+ Shto koment të ri", - "noComments": "Asnjë koment ende. Bëhu i pari që komenton!", + "noComments": "Ende pa komente. Bëhu i pari që komenton!", "delete": "Fshi", - "confirmDeleteComment": "Jeni i sigurt që doni të fshini këtë koment?" + "confirmDeleteComment": "Jeni i sigurt që doni të fshini këtë koment?", + "addCommentPlaceholder": "Shto një koment...", + "cancel": "Anulo", + "commentButton": "Komento", + "attachFiles": "Bashkëngjit skedarë", + "addMoreFiles": "Shto më shumë skedarë", + "selectedFiles": "Skedarët e Zgjedhur (Deri në 25MB, Maksimumi {count})", + "maxFilesError": "Mund të ngarkoni maksimum {count} skedarë", + "processFilesError": "Dështoi përpunimi i skedarëve", + "addCommentError": "Ju lutemi shtoni një koment ose bashkëngjitni skedarë", + "createdBy": "Krijuar {time} nga {user}", + "updatedTime": "Përditësuar {time}" }, "searchInputPlaceholder": "Kërko sipas emrit", "pendingInvitation": "Ftesë në Pritje" @@ -68,11 +88,36 @@ "taskTimeLogTab": { "title": "Regjistri i Kohës", "addTimeLog": "Shto regjistrim të ri kohe", - "totalLogged": "Koha totale e regjistruar", + "totalLogged": "Totali i Regjistruar", "exportToExcel": "Eksporto në Excel", - "noTimeLogsFound": "Asnjë regjistrim kohe nuk u gjet" + "noTimeLogsFound": "Nuk u gjetën regjistra kohe", + "timeLogForm": { + "date": "Data", + "startTime": "Koha e Fillimit", + "endTime": "Koha e Përfundimit", + "workDescription": "Përshkrimi i Punës", + "descriptionPlaceholder": "Shto një përshkrim", + "logTime": "Regjistro kohën", + "updateTime": "Përditëso kohën", + "cancel": "Anulo", + "selectDateError": "Ju lutemi zgjidhni një datë", + "selectStartTimeError": "Ju lutemi zgjidhni kohën e fillimit", + "selectEndTimeError": "Ju lutemi zgjidhni kohën e përfundimit", + "endTimeAfterStartError": "Koha e përfundimit duhet të jetë pas kohës së fillimit" + } }, "taskActivityLogTab": { - "title": "Regjistri i Aktivitetit" + "title": "Regjistri i Aktivitetit", + "add": "SHTO", + "remove": "HIQE", + "none": "Asnjë", + "weight": "Pesha", + "createdTask": "krijoi detyrën." + }, + "taskProgress": { + "markAsDoneTitle": "Shëno Detyrën si të Kryer?", + "confirmMarkAsDone": "Po, shëno si të kryer", + "cancelMarkAsDone": "Jo, mbaj statusin aktual", + "markAsDoneDescription": "Keni vendosur progresin në 100%. Doni të përditësoni statusin e detyrës në \"Kryer\"?" } } diff --git a/worklenz-frontend/public/locales/alb/task-list-table.json b/worklenz-frontend/public/locales/alb/task-list-table.json index 067d1088..c6e1dc44 100644 --- a/worklenz-frontend/public/locales/alb/task-list-table.json +++ b/worklenz-frontend/public/locales/alb/task-list-table.json @@ -68,6 +68,13 @@ "dueDatePlaceholder": "Data e afatit", "startDatePlaceholder": "Data e fillimit", + "emptyStates": { + "noTaskGroups": "Nuk u gjetën grupe detyrash", + "noTaskGroupsDescription": "Detyrat do të shfaqen këtu kur krijohen ose kur aplikohen filtra.", + "errorPrefix": "Gabim:", + "dragTaskFallback": "Detyrë" + }, + "customColumns": { "addCustomColumn": "Shto një kolonë të personalizuar", "customColumnHeader": "Kolona e Personalizuar", diff --git a/worklenz-frontend/public/locales/de/project-view.json b/worklenz-frontend/public/locales/de/project-view.json new file mode 100644 index 00000000..448a7249 --- /dev/null +++ b/worklenz-frontend/public/locales/de/project-view.json @@ -0,0 +1,14 @@ +{ + "taskList": "Aufgabenliste", + "board": "Kanban-Board", + "insights": "Insights", + "files": "Dateien", + "members": "Mitglieder", + "updates": "Aktualisierungen", + "projectView": "Projektansicht", + "loading": "Projekt wird geladen...", + "error": "Fehler beim Laden des Projekts", + "pinnedTab": "Als Standard-Registerkarte festgesetzt", + "pinTab": "Als Standard-Registerkarte festsetzen", + "unpinTab": "Standard-Registerkarte lösen" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/de/project-view/project-view-header.json b/worklenz-frontend/public/locales/de/project-view/project-view-header.json index e2810462..dae5f67a 100644 --- a/worklenz-frontend/public/locales/de/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/de/project-view/project-view-header.json @@ -4,7 +4,7 @@ "createTask": "Aufgabe erstellen", "settings": "Einstellungen", "subscribe": "Abonnieren", - "unsubscribe": "Abbestellen", + "unsubscribe": "Abonnement beenden", "deleteProject": "Projekt löschen", "startDate": "Startdatum", "endDate": "Enddatum", @@ -13,5 +13,17 @@ "receiveProjectSummary": "Erhalten Sie jeden Abend eine Projektzusammenfassung.", "refreshProject": "Projekt aktualisieren", "saveAsTemplate": "Als Vorlage speichern", - "invite": "Einladen" + "invite": "Einladen", + "subscribeTooltip": "Projektbenachrichtigungen abonnieren", + "unsubscribeTooltip": "Projektbenachrichtigungen beenden", + "refreshTooltip": "Projektdaten aktualisieren", + "settingsTooltip": "Projekteinstellungen öffnen", + "saveAsTemplateTooltip": "Dieses Projekt als Vorlage speichern", + "inviteTooltip": "Teammitglieder zu diesem Projekt einladen", + "createTaskTooltip": "Neue Aufgabe erstellen", + "importTaskTooltip": "Aufgabe aus Vorlage importieren", + "navigateBackTooltip": "Zurück zur Projektliste", + "projectStatusTooltip": "Projektstatus", + "projectDatesInfo": "Informationen zum Projektzeitraum", + "projectCategoryTooltip": "Projektkategorie" } diff --git a/worklenz-frontend/public/locales/de/settings/profile.json b/worklenz-frontend/public/locales/de/settings/profile.json index f896e1f8..4d7fc4cd 100644 --- a/worklenz-frontend/public/locales/de/settings/profile.json +++ b/worklenz-frontend/public/locales/de/settings/profile.json @@ -9,5 +9,6 @@ "saveChanges": "Änderungen speichern", "profileJoinedText": "Vor einem Monat beigetreten", "profileLastUpdatedText": "Vor einem Monat aktualisiert", - "avatarTooltip": "Klicken Sie zum Hochladen eines Avatars" + "avatarTooltip": "Klicken Sie zum Hochladen eines Avatars", + "title": "Profil-Einstellungen" } diff --git a/worklenz-frontend/public/locales/de/settings/team-members.json b/worklenz-frontend/public/locales/de/settings/team-members.json index 6f2add12..d223f08e 100644 --- a/worklenz-frontend/public/locales/de/settings/team-members.json +++ b/worklenz-frontend/public/locales/de/settings/team-members.json @@ -1,4 +1,5 @@ { + "title": "Teammitglieder", "nameColumn": "Name", "projectsColumn": "Projekte", "emailColumn": "E-Mail", @@ -40,5 +41,7 @@ "ownerText": "Team-Besitzer", "addedText": "Hinzugefügt", "updatedText": "Aktualisiert", - "noResultFound": "Geben Sie eine E-Mail-Adresse ein und drücken Sie Enter..." + "noResultFound": "Geben Sie eine E-Mail-Adresse ein und drücken Sie Enter...", + "jobTitlesFetchError": "Fehler beim Abrufen der Jobtitel", + "invitationResent": "Einladung erfolgreich erneut gesendet!" } diff --git a/worklenz-frontend/public/locales/de/settings/teams.json b/worklenz-frontend/public/locales/de/settings/teams.json new file mode 100644 index 00000000..bf39215d --- /dev/null +++ b/worklenz-frontend/public/locales/de/settings/teams.json @@ -0,0 +1,16 @@ +{ + "title": "Teams", + "team": "Team", + "teams": "Teams", + "name": "Name", + "created": "Erstellt", + "ownsBy": "Gehört zu", + "edit": "Bearbeiten", + "editTeam": "Team bearbeiten", + "pinTooltip": "Klicken Sie hier, um dies im Hauptmenü zu fixieren", + "editTeamName": "Team-Name bearbeiten", + "updateName": "Name aktualisieren", + "namePlaceholder": "Name", + "nameRequired": "Bitte geben Sie einen Namen ein", + "updateFailed": "Änderung des Team-Namens fehlgeschlagen!" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/de/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/de/task-drawer/task-drawer.json index b4aa0525..5d4b4275 100644 --- a/worklenz-frontend/public/locales/de/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/de/task-drawer/task-drawer.json @@ -1,6 +1,6 @@ { "taskHeader": { - "taskNamePlaceholder": "Aufgabe eingeben", + "taskNamePlaceholder": "Geben Sie Ihre Aufgabe ein", "deleteTask": "Aufgabe löschen" }, "taskInfoTab": { @@ -9,20 +9,29 @@ "title": "Details", "task-key": "Aufgaben-Schlüssel", "phase": "Phase", - "assignees": "Zugewiesene", + "assignees": "Beauftragte", "due-date": "Fälligkeitsdatum", "time-estimation": "Zeitschätzung", "priority": "Priorität", "labels": "Labels", "billable": "Abrechenbar", "notify": "Benachrichtigen", - "when-done-notify": "Bei Fertigstellung benachrichtigen", + "when-done-notify": "Bei Abschluss benachrichtigen", "start-date": "Startdatum", "end-date": "Enddatum", "hide-start-date": "Startdatum ausblenden", "show-start-date": "Startdatum anzeigen", "hours": "Stunden", - "minutes": "Minuten" + "minutes": "Minuten", + "progressValue": "Fortschrittswert", + "progressValueTooltip": "Fortschritt in Prozent einstellen (0-100%)", + "progressValueRequired": "Bitte geben Sie einen Fortschrittswert ein", + "progressValueRange": "Fortschritt muss zwischen 0 und 100 liegen", + "taskWeight": "Aufgabengewicht", + "taskWeightTooltip": "Gewicht dieser Teilaufgabe festlegen (Prozent)", + "taskWeightRequired": "Bitte geben Sie ein Aufgabengewicht ein", + "taskWeightRange": "Gewicht muss zwischen 0 und 100 liegen", + "recurring": "Wiederkehrend" }, "labels": { "labelInputPlaceholder": "Suchen oder erstellen", @@ -30,29 +39,29 @@ }, "description": { "title": "Beschreibung", - "placeholder": "Detaillierte Beschreibung hinzufügen..." + "placeholder": "Detailliertere Beschreibung hinzufügen..." }, "subTasks": { - "title": "Unteraufgaben", - "addSubTask": "+ Unteraufgabe hinzufügen", - "addSubTaskInputPlaceholder": "Aufgabe eingeben und Enter drücken", - "refreshSubTasks": "Unteraufgaben aktualisieren", + "title": "Teilaufgaben", + "addSubTask": "Teilaufgabe hinzufügen", + "addSubTaskInputPlaceholder": "Geben Sie Ihre Aufgabe ein und drücken Sie Enter", + "refreshSubTasks": "Teilaufgaben aktualisieren", "edit": "Bearbeiten", "delete": "Löschen", - "confirmDeleteSubTask": "Sind Sie sicher, dass Sie diese Unteraufgabe löschen möchten?", - "deleteSubTask": "Unteraufgabe löschen" + "confirmDeleteSubTask": "Sind Sie sicher, dass Sie diese Teilaufgabe löschen möchten?", + "deleteSubTask": "Teilaufgabe löschen" }, "dependencies": { "title": "Abhängigkeiten", "addDependency": "+ Neue Abhängigkeit hinzufügen", - "blockedBy": "Blockiert durch", + "blockedBy": "Blockiert von", "searchTask": "Aufgabe suchen", "noTasksFound": "Keine Aufgaben gefunden", - "confirmDeleteDependency": "Sind Sie sicher, dass Sie dies löschen möchten?" + "confirmDeleteDependency": "Sind Sie sicher, dass Sie löschen möchten?" }, "attachments": { "title": "Anhänge", - "chooseOrDropFileToUpload": "Datei auswählen oder zum Hochladen ablegen", + "chooseOrDropFileToUpload": "Datei zum Hochladen wählen oder ablegen", "uploading": "Wird hochgeladen..." }, "comments": { @@ -60,19 +69,55 @@ "addComment": "+ Neuen Kommentar hinzufügen", "noComments": "Noch keine Kommentare. Seien Sie der Erste!", "delete": "Löschen", - "confirmDeleteComment": "Sind Sie sicher, dass Sie diesen Kommentar löschen möchten?" + "confirmDeleteComment": "Sind Sie sicher, dass Sie diesen Kommentar löschen möchten?", + "addCommentPlaceholder": "Kommentar hinzufügen...", + "cancel": "Abbrechen", + "commentButton": "Kommentieren", + "attachFiles": "Dateien anhängen", + "addMoreFiles": "Weitere Dateien hinzufügen", + "selectedFiles": "Ausgewählte Dateien (Bis zu 25MB, Maximum {count})", + "maxFilesError": "Sie können maximal {count} Dateien hochladen", + "processFilesError": "Fehler beim Verarbeiten der Dateien", + "addCommentError": "Bitte fügen Sie einen Kommentar hinzu oder hängen Sie Dateien an", + "createdBy": "Erstellt {time} von {user}", + "updatedTime": "Aktualisiert {time}" }, - "searchInputPlaceholder": "Nach Namen suchen", - "pendingInvitation": "Einladung ausstehend" + "searchInputPlaceholder": "Nach Name suchen", + "pendingInvitation": "Ausstehende Einladung" }, "taskTimeLogTab": { "title": "Zeiterfassung", "addTimeLog": "Neuen Zeiteintrag hinzufügen", "totalLogged": "Gesamt erfasst", "exportToExcel": "Nach Excel exportieren", - "noTimeLogsFound": "Keine Zeiterfassungen gefunden" + "noTimeLogsFound": "Keine Zeiteinträge gefunden", + "timeLogForm": { + "date": "Datum", + "startTime": "Startzeit", + "endTime": "Endzeit", + "workDescription": "Arbeitsbeschreibung", + "descriptionPlaceholder": "Beschreibung hinzufügen", + "logTime": "Zeit erfassen", + "updateTime": "Zeit aktualisieren", + "cancel": "Abbrechen", + "selectDateError": "Bitte wählen Sie ein Datum", + "selectStartTimeError": "Bitte wählen Sie eine Startzeit", + "selectEndTimeError": "Bitte wählen Sie eine Endzeit", + "endTimeAfterStartError": "Endzeit muss nach der Startzeit liegen" + } }, "taskActivityLogTab": { - "title": "Aktivitätsprotokoll" + "title": "Aktivitätsprotokoll", + "add": "HINZUFÜGEN", + "remove": "ENTFERNEN", + "none": "Keine", + "weight": "Gewicht", + "createdTask": "hat die Aufgabe erstellt." + }, + "taskProgress": { + "markAsDoneTitle": "Aufgabe als erledigt markieren?", + "confirmMarkAsDone": "Ja, als erledigt markieren", + "cancelMarkAsDone": "Nein, aktuellen Status beibehalten", + "markAsDoneDescription": "Sie haben den Fortschritt auf 100% gesetzt. Möchten Sie den Aufgabenstatus auf \"Erledigt\" aktualisieren?" } } diff --git a/worklenz-frontend/public/locales/de/task-list-table.json b/worklenz-frontend/public/locales/de/task-list-table.json index fa8e7623..2caa8e5c 100644 --- a/worklenz-frontend/public/locales/de/task-list-table.json +++ b/worklenz-frontend/public/locales/de/task-list-table.json @@ -68,6 +68,13 @@ "dueDatePlaceholder": "Fälligkeitsdatum", "startDatePlaceholder": "Startdatum", + "emptyStates": { + "noTaskGroups": "Keine Aufgabengruppen gefunden", + "noTaskGroupsDescription": "Aufgaben werden hier angezeigt, wenn sie erstellt oder Filter angewendet werden.", + "errorPrefix": "Fehler:", + "dragTaskFallback": "Aufgabe" + }, + "customColumns": { "addCustomColumn": "Benutzerdefinierte Spalte hinzufügen", "customColumnHeader": "Benutzerdefinierte Spalte", diff --git a/worklenz-frontend/public/locales/en/project-view.json b/worklenz-frontend/public/locales/en/project-view.json new file mode 100644 index 00000000..16d2a0bc --- /dev/null +++ b/worklenz-frontend/public/locales/en/project-view.json @@ -0,0 +1,14 @@ +{ + "taskList": "Task List", + "board": "Kanban Board", + "insights": "Insights", + "files": "Files", + "members": "Members", + "updates": "Updates", + "projectView": "Project View", + "loading": "Loading project...", + "error": "Error loading project", + "pinnedTab": "Pinned as default tab", + "pinTab": "Pin as default tab", + "unpinTab": "Unpin default tab" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/en/project-view/project-view-header.json b/worklenz-frontend/public/locales/en/project-view/project-view-header.json index c8467288..536ccad4 100644 --- a/worklenz-frontend/public/locales/en/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/en/project-view/project-view-header.json @@ -13,5 +13,17 @@ "receiveProjectSummary": "Receive a project summary every evening.", "refreshProject": "Refresh project", "saveAsTemplate": "Save as template", - "invite": "Invite" + "invite": "Invite", + "subscribeTooltip": "Subscribe to project notifications", + "unsubscribeTooltip": "Unsubscribe from project notifications", + "refreshTooltip": "Refresh project data", + "settingsTooltip": "Open project settings", + "saveAsTemplateTooltip": "Save this project as a template", + "inviteTooltip": "Invite team members to this project", + "createTaskTooltip": "Create a new task", + "importTaskTooltip": "Import task from template", + "navigateBackTooltip": "Go back to projects list", + "projectStatusTooltip": "Project status", + "projectDatesInfo": "Project timeline information", + "projectCategoryTooltip": "Project category" } diff --git a/worklenz-frontend/public/locales/en/settings/profile.json b/worklenz-frontend/public/locales/en/settings/profile.json index 5dd49095..43ce2f41 100644 --- a/worklenz-frontend/public/locales/en/settings/profile.json +++ b/worklenz-frontend/public/locales/en/settings/profile.json @@ -9,5 +9,6 @@ "saveChanges": "Save Changes", "profileJoinedText": "Joined a month ago", "profileLastUpdatedText": "Last updated a month ago", - "avatarTooltip": "Click to upload an avatar" + "avatarTooltip": "Click to upload an avatar", + "title": "Profile Settings" } diff --git a/worklenz-frontend/public/locales/en/settings/team-members.json b/worklenz-frontend/public/locales/en/settings/team-members.json index 35e77f6e..36918b90 100644 --- a/worklenz-frontend/public/locales/en/settings/team-members.json +++ b/worklenz-frontend/public/locales/en/settings/team-members.json @@ -1,4 +1,5 @@ { + "title": "Team Members", "nameColumn": "Name", "projectsColumn": "Projects", "emailColumn": "Email", @@ -40,5 +41,7 @@ "ownerText": "Team Owner", "addedText": "Added", "updatedText": "Updated", - "noResultFound": "Type an email address and hit enter..." + "noResultFound": "Type an email address and hit enter...", + "jobTitlesFetchError": "Failed to fetch job titles", + "invitationResent": "Invitation resent successfully!" } diff --git a/worklenz-frontend/public/locales/en/settings/teams.json b/worklenz-frontend/public/locales/en/settings/teams.json new file mode 100644 index 00000000..57a1df51 --- /dev/null +++ b/worklenz-frontend/public/locales/en/settings/teams.json @@ -0,0 +1,16 @@ +{ + "title": "Teams", + "team": "Team", + "teams": "Teams", + "name": "Name", + "created": "Created", + "ownsBy": "Owns By", + "edit": "Edit", + "editTeam": "Edit Team", + "pinTooltip": "Click to pin this into the main menu", + "editTeamName": "Edit Team Name", + "updateName": "Update Name", + "namePlaceholder": "Name", + "nameRequired": "Please enter a Name", + "updateFailed": "Team name change failed!" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json index ab271810..83955de7 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json @@ -69,7 +69,18 @@ "addComment": "+ Add new comment", "noComments": "No comments yet. Be the first to comment!", "delete": "Delete", - "confirmDeleteComment": "Are you sure you want to delete this comment?" + "confirmDeleteComment": "Are you sure you want to delete this comment?", + "addCommentPlaceholder": "Add a comment...", + "cancel": "Cancel", + "commentButton": "Comment", + "attachFiles": "Attach files", + "addMoreFiles": "Add more files", + "selectedFiles": "Selected Files (Up to 25MB, Maximum of {count})", + "maxFilesError": "You can only upload a maximum of {count} files", + "processFilesError": "Failed to process files", + "addCommentError": "Please add a comment or attach files", + "createdBy": "Created {time} by {user}", + "updatedTime": "Updated {time}" }, "searchInputPlaceholder": "Search by name", "pendingInvitation": "Pending Invitation" @@ -79,10 +90,29 @@ "addTimeLog": "Add new time log", "totalLogged": "Total Logged", "exportToExcel": "Export to Excel", - "noTimeLogsFound": "No time logs found" + "noTimeLogsFound": "No time logs found", + "timeLogForm": { + "date": "Date", + "startTime": "Start Time", + "endTime": "End Time", + "workDescription": "Work Description", + "descriptionPlaceholder": "Add a description", + "logTime": "Log time", + "updateTime": "Update time", + "cancel": "Cancel", + "selectDateError": "Please select a date", + "selectStartTimeError": "Please select start time", + "selectEndTimeError": "Please select end time", + "endTimeAfterStartError": "End time must be after start time" + } }, "taskActivityLogTab": { - "title": "Activity Log" + "title": "Activity Log", + "add": "ADD", + "remove": "REMOVE", + "none": "None", + "weight": "Weight", + "createdTask": "created the task." }, "taskProgress": { "markAsDoneTitle": "Mark Task as Done?", diff --git a/worklenz-frontend/public/locales/en/task-list-table.json b/worklenz-frontend/public/locales/en/task-list-table.json index 674f12d0..adea199f 100644 --- a/worklenz-frontend/public/locales/en/task-list-table.json +++ b/worklenz-frontend/public/locales/en/task-list-table.json @@ -68,6 +68,13 @@ "dueDatePlaceholder": "Due Date", "startDatePlaceholder": "Start Date", + "emptyStates": { + "noTaskGroups": "No task groups found", + "noTaskGroupsDescription": "Tasks will appear here when they are created or when filters are applied.", + "errorPrefix": "Error:", + "dragTaskFallback": "Task" + }, + "customColumns": { "addCustomColumn": "Add a custom column", "customColumnHeader": "Custom Column", diff --git a/worklenz-frontend/public/locales/es/project-view.json b/worklenz-frontend/public/locales/es/project-view.json new file mode 100644 index 00000000..a4c12d9f --- /dev/null +++ b/worklenz-frontend/public/locales/es/project-view.json @@ -0,0 +1,14 @@ +{ + "taskList": "Lista de Tareas", + "board": "Tablero Kanban", + "insights": "Análisis", + "files": "Archivos", + "members": "Miembros", + "updates": "Actualizaciones", + "projectView": "Vista del Proyecto", + "loading": "Cargando proyecto...", + "error": "Error al cargar el proyecto", + "pinnedTab": "Fijado como pestaña predeterminada", + "pinTab": "Fijar como pestaña predeterminada", + "unpinTab": "Desfijar pestaña predeterminada" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/project-view/project-view-header.json b/worklenz-frontend/public/locales/es/project-view/project-view-header.json index 0d9bdf26..c6fb854b 100644 --- a/worklenz-frontend/public/locales/es/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/es/project-view/project-view-header.json @@ -2,16 +2,28 @@ "importTasks": "Importar tareas", "importTask": "Importar tarea", "createTask": "Crear tarea", - "settings": "Ajustes", + "settings": "Configuración", "subscribe": "Suscribirse", "unsubscribe": "Cancelar suscripción", "deleteProject": "Eliminar proyecto", "startDate": "Fecha de inicio", "endDate": "Fecha de finalización", - "projectSettings": "Ajustes del proyecto", + "projectSettings": "Configuración del proyecto", "projectSummary": "Resumen del proyecto", - "receiveProjectSummary": "Recibir un resumen del proyecto todas las noches.", + "receiveProjectSummary": "Recibe un resumen del proyecto cada noche.", "refreshProject": "Actualizar proyecto", "saveAsTemplate": "Guardar como plantilla", - "invite": "Invitar" + "invite": "Invitar", + "subscribeTooltip": "Suscribirse a notificaciones del proyecto", + "unsubscribeTooltip": "Cancelar suscripción a notificaciones del proyecto", + "refreshTooltip": "Actualizar datos del proyecto", + "settingsTooltip": "Abrir configuración del proyecto", + "saveAsTemplateTooltip": "Guardar este proyecto como plantilla", + "inviteTooltip": "Invitar miembros del equipo a este proyecto", + "createTaskTooltip": "Crear una nueva tarea", + "importTaskTooltip": "Importar tarea desde plantilla", + "navigateBackTooltip": "Volver a la lista de proyectos", + "projectStatusTooltip": "Estado del proyecto", + "projectDatesInfo": "Información de cronograma del proyecto", + "projectCategoryTooltip": "Categoría del proyecto" } diff --git a/worklenz-frontend/public/locales/es/settings/profile.json b/worklenz-frontend/public/locales/es/settings/profile.json index 9c43a470..1a1698c8 100644 --- a/worklenz-frontend/public/locales/es/settings/profile.json +++ b/worklenz-frontend/public/locales/es/settings/profile.json @@ -9,5 +9,6 @@ "saveChanges": "Guardar cambios", "profileJoinedText": "Se unió hace un mes", "profileLastUpdatedText": "Última actualización hace un mes", - "avatarTooltip": "Haz clic para subir un avatar" + "avatarTooltip": "Haz clic para subir un avatar", + "title": "Configuración del Perfil" } diff --git a/worklenz-frontend/public/locales/es/settings/team-members.json b/worklenz-frontend/public/locales/es/settings/team-members.json index 8de73b84..1000bf98 100644 --- a/worklenz-frontend/public/locales/es/settings/team-members.json +++ b/worklenz-frontend/public/locales/es/settings/team-members.json @@ -1,4 +1,5 @@ { + "title": "Miembros del Equipo", "nameColumn": "Nombre", "projectsColumn": "Proyectos", "emailColumn": "Correo electrónico", @@ -40,5 +41,7 @@ "ownerText": "Propietario del equipo", "addedText": "Agregado", "updatedText": "Actualizado", - "noResultFound": "Escriba una dirección de correo electrónico y presione enter..." + "noResultFound": "Escriba una dirección de correo electrónico y presione enter...", + "jobTitlesFetchError": "Error al obtener los cargos", + "invitationResent": "¡Invitación reenviada exitosamente!" } diff --git a/worklenz-frontend/public/locales/es/settings/teams.json b/worklenz-frontend/public/locales/es/settings/teams.json new file mode 100644 index 00000000..808c1b78 --- /dev/null +++ b/worklenz-frontend/public/locales/es/settings/teams.json @@ -0,0 +1,16 @@ +{ + "title": "Equipos", + "team": "Equipo", + "teams": "Equipos", + "name": "Nombre", + "created": "Creado", + "ownsBy": "Pertenece a", + "edit": "Editar", + "editTeam": "Editar Equipo", + "pinTooltip": "Haz clic para fijar esto en el menú principal", + "editTeamName": "Editar Nombre del Equipo", + "updateName": "Actualizar Nombre", + "namePlaceholder": "Nombre", + "nameRequired": "Por favor ingresa un Nombre", + "updateFailed": "¡Falló el cambio de nombre del equipo!" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json index e1462fbe..0e92e24f 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json @@ -1,93 +1,123 @@ { "taskHeader": { - "taskNamePlaceholder": "Escribe tu tarea", - "deleteTask": "Eliminar tarea" + "taskNamePlaceholder": "Escriba su Tarea", + "deleteTask": "Eliminar Tarea" }, "taskInfoTab": { "title": "Información", "details": { "title": "Detalles", - "task-key": "Clave de tarea", + "task-key": "Clave de Tarea", "phase": "Fase", "assignees": "Asignados", - "due-date": "Fecha de vencimiento", - "time-estimation": "Estimación de tiempo", + "due-date": "Fecha de Vencimiento", + "time-estimation": "Estimación de Tiempo", "priority": "Prioridad", "labels": "Etiquetas", "billable": "Facturable", "notify": "Notificar", "when-done-notify": "Al terminar, notificar", - "start-date": "Fecha de inicio", - "end-date": "Fecha de finalización", - "hide-start-date": "Ocultar fecha de inicio", - "show-start-date": "Mostrar fecha de inicio", + "start-date": "Fecha de Inicio", + "end-date": "Fecha de Fin", + "hide-start-date": "Ocultar Fecha de Inicio", + "show-start-date": "Mostrar Fecha de Inicio", "hours": "Horas", "minutes": "Minutos", "progressValue": "Valor de Progreso", "progressValueTooltip": "Establecer el porcentaje de progreso (0-100%)", - "progressValueRequired": "Por favor, introduce un valor de progreso", + "progressValueRequired": "Por favor, introduzca un valor de progreso", "progressValueRange": "El progreso debe estar entre 0 y 100", "taskWeight": "Peso de la Tarea", "taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)", - "taskWeightRequired": "Por favor, introduce un peso para la tarea", + "taskWeightRequired": "Por favor, introduzca un peso de tarea", "taskWeightRange": "El peso debe estar entre 0 y 100", "recurring": "Recurrente" }, "labels": { "labelInputPlaceholder": "Buscar o crear", - "labelsSelectorInputTip": "Pulse Enter para crear" + "labelsSelectorInputTip": "Presiona Enter para crear" }, "description": { "title": "Descripción", "placeholder": "Añadir una descripción más detallada..." }, "subTasks": { - "title": "Subtareas", - "addSubTask": "+ Añadir subtarea", - "addSubTaskInputPlaceholder": "Escribe tu tarea y pulsa enter", - "refreshSubTasks": "Actualizar subtareas", + "title": "Sub Tareas", + "addSubTask": "Agregar Sub Tarea", + "addSubTaskInputPlaceholder": "Escriba su tarea y presione enter", + "refreshSubTasks": "Actualizar Sub Tareas", "edit": "Editar", "delete": "Eliminar", - "confirmDeleteSubTask": "¿Estás seguro de que quieres eliminar esta subtarea?", - "deleteSubTask": "Eliminar subtarea" + "confirmDeleteSubTask": "¿Está seguro de que desea eliminar esta subtarea?", + "deleteSubTask": "Eliminar Sub Tarea" }, "dependencies": { "title": "Dependencias", - "addDependency": "+ Añadir nueva dependencia", + "addDependency": "+ Agregar nueva dependencia", "blockedBy": "Bloqueado por", - "searchTask": "Escribe para buscar tarea", + "searchTask": "Escribir para buscar tarea", "noTasksFound": "No se encontraron tareas", - "confirmDeleteDependency": "¿Estás seguro de que quieres eliminar?" + "confirmDeleteDependency": "¿Está seguro de que desea eliminar?" }, "attachments": { "title": "Adjuntos", - "chooseOrDropFileToUpload": "Elige o arrastra un archivo para subir", + "chooseOrDropFileToUpload": "Elija o arrastre un archivo para subir", "uploading": "Subiendo..." }, "comments": { "title": "Comentarios", - "addComment": "+ Añadir nuevo comentario", - "noComments": "No hay comentarios todavía. ¡Sé el primero en comentar!", + "addComment": "+ Agregar nuevo comentario", + "noComments": "Aún no hay comentarios. ¡Sé el primero en comentar!", "delete": "Eliminar", - "confirmDeleteComment": "¿Estás seguro de que quieres eliminar este comentario?" + "confirmDeleteComment": "¿Está seguro de que desea eliminar este comentario?", + "addCommentPlaceholder": "Agregar un comentario...", + "cancel": "Cancelar", + "commentButton": "Comentar", + "attachFiles": "Adjuntar archivos", + "addMoreFiles": "Agregar más archivos", + "selectedFiles": "Archivos Seleccionados (Hasta 25MB, Máximo {count})", + "maxFilesError": "Solo puede subir un máximo de {count} archivos", + "processFilesError": "Error al procesar archivos", + "addCommentError": "Por favor agregue un comentario o adjunte archivos", + "createdBy": "Creado {time} por {user}", + "updatedTime": "Actualizado {time}" }, "searchInputPlaceholder": "Buscar por nombre", - "pendingInvitation": "Invitación pendiente" + "pendingInvitation": "Invitación Pendiente" }, "taskTimeLogTab": { - "title": "Registro de tiempo", + "title": "Registro de Tiempo", "addTimeLog": "Añadir nuevo registro de tiempo", - "totalLogged": "Total registrado", + "totalLogged": "Total Registrado", "exportToExcel": "Exportar a Excel", - "noTimeLogsFound": "No se encontraron registros de tiempo" + "noTimeLogsFound": "No se encontraron registros de tiempo", + "timeLogForm": { + "date": "Fecha", + "startTime": "Hora de Inicio", + "endTime": "Hora de Fin", + "workDescription": "Descripción del Trabajo", + "descriptionPlaceholder": "Agregar una descripción", + "logTime": "Registrar tiempo", + "updateTime": "Actualizar tiempo", + "cancel": "Cancelar", + "selectDateError": "Por favor seleccione una fecha", + "selectStartTimeError": "Por favor seleccione la hora de inicio", + "selectEndTimeError": "Por favor seleccione la hora de fin", + "endTimeAfterStartError": "La hora de fin debe ser posterior a la hora de inicio" + } }, "taskActivityLogTab": { - "title": "Registro de actividad" + "title": "Registro de Actividad", + "add": "AGREGAR", + "remove": "QUITAR", + "none": "Ninguno", + "weight": "Peso", + "createdTask": "creó la tarea." }, "taskProgress": { "markAsDoneTitle": "¿Marcar Tarea como Completada?", "confirmMarkAsDone": "Sí, marcar como completada", "cancelMarkAsDone": "No, mantener estado actual", - "markAsDoneDescription": "Has establecido el progreso al 100%. ¿Quieres actualizar el estado de la tarea a \"Completada\"?" + "markAsDoneDescription": "Ha establecido el progreso al 100%. ¿Le gustaría actualizar el estado de la tarea a \"Completada\"?" } } diff --git a/worklenz-frontend/public/locales/es/task-list-table.json b/worklenz-frontend/public/locales/es/task-list-table.json index 006a2763..c67225de 100644 --- a/worklenz-frontend/public/locales/es/task-list-table.json +++ b/worklenz-frontend/public/locales/es/task-list-table.json @@ -68,6 +68,13 @@ "dueDatePlaceholder": "Fecha de vencimiento", "startDatePlaceholder": "Fecha de inicio", + "emptyStates": { + "noTaskGroups": "No se encontraron grupos de tareas", + "noTaskGroupsDescription": "Las tareas aparecerán aquí cuando se creen o cuando se apliquen filtros.", + "errorPrefix": "Error:", + "dragTaskFallback": "Tarea" + }, + "customColumns": { "addCustomColumn": "Agregar una columna personalizada", "customColumnHeader": "Columna Personalizada", diff --git a/worklenz-frontend/public/locales/pt/project-view.json b/worklenz-frontend/public/locales/pt/project-view.json new file mode 100644 index 00000000..c58337da --- /dev/null +++ b/worklenz-frontend/public/locales/pt/project-view.json @@ -0,0 +1,14 @@ +{ + "taskList": "Lista de Tarefas", + "board": "Quadro Kanban", + "insights": "Insights", + "files": "Arquivos", + "members": "Membros", + "updates": "Atualizações", + "projectView": "Visualização do Projeto", + "loading": "Carregando projeto...", + "error": "Erro ao carregar projeto", + "pinnedTab": "Fixada como aba padrão", + "pinTab": "Fixar como aba padrão", + "unpinTab": "Desfixar aba padrão" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/project-view/project-view-header.json b/worklenz-frontend/public/locales/pt/project-view/project-view-header.json index e776c67d..6e295e38 100644 --- a/worklenz-frontend/public/locales/pt/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/pt/project-view/project-view-header.json @@ -7,11 +7,23 @@ "unsubscribe": "Cancelar inscrição", "deleteProject": "Excluir projeto", "startDate": "Data de início", - "endDate": "Data de fim", + "endDate": "Data de término", "projectSettings": "Configurações do projeto", "projectSummary": "Resumo do projeto", - "receiveProjectSummary": "Receber um resumo do projeto todas as noites.", + "receiveProjectSummary": "Receba um resumo do projeto todas as noites.", "refreshProject": "Atualizar projeto", "saveAsTemplate": "Salvar como modelo", - "invite": "Convidar" + "invite": "Convidar", + "subscribeTooltip": "Inscrever-se nas notificações do projeto", + "unsubscribeTooltip": "Cancelar inscrição nas notificações do projeto", + "refreshTooltip": "Atualizar dados do projeto", + "settingsTooltip": "Abrir configurações do projeto", + "saveAsTemplateTooltip": "Salvar este projeto como modelo", + "inviteTooltip": "Convidar membros da equipe para este projeto", + "createTaskTooltip": "Criar uma nova tarefa", + "importTaskTooltip": "Importar tarefa de modelo", + "navigateBackTooltip": "Voltar para lista de projetos", + "projectStatusTooltip": "Status do projeto", + "projectDatesInfo": "Informações do cronograma do projeto", + "projectCategoryTooltip": "Categoria do projeto" } diff --git a/worklenz-frontend/public/locales/pt/settings/profile.json b/worklenz-frontend/public/locales/pt/settings/profile.json index 61e94e8b..3a4a8447 100644 --- a/worklenz-frontend/public/locales/pt/settings/profile.json +++ b/worklenz-frontend/public/locales/pt/settings/profile.json @@ -9,5 +9,6 @@ "saveChanges": "Salvar Alterações", "profileJoinedText": "Entrou há um mês", "profileLastUpdatedText": "Última atualização há um mês", - "avatarTooltip": "Clique para carregar um avatar" + "avatarTooltip": "Clique para carregar um avatar", + "title": "Configurações do Perfil" } diff --git a/worklenz-frontend/public/locales/pt/settings/team-members.json b/worklenz-frontend/public/locales/pt/settings/team-members.json index 9c6d80b6..9ace1764 100644 --- a/worklenz-frontend/public/locales/pt/settings/team-members.json +++ b/worklenz-frontend/public/locales/pt/settings/team-members.json @@ -1,4 +1,5 @@ { + "title": "Membros da Equipe", "nameColumn": "Nome", "projectsColumn": "Projetos", "emailColumn": "Email", @@ -40,5 +41,7 @@ "ownerText": "Dono da Equipe", "addedText": "Adicionado", "updatedText": "Atualizado", - "noResultFound": "Digite um endereço de email e pressione enter..." + "noResultFound": "Digite um endereço de email e pressione enter...", + "jobTitlesFetchError": "Falha ao buscar cargos", + "invitationResent": "Convite reenviado com sucesso!" } diff --git a/worklenz-frontend/public/locales/pt/settings/teams.json b/worklenz-frontend/public/locales/pt/settings/teams.json new file mode 100644 index 00000000..e460318f --- /dev/null +++ b/worklenz-frontend/public/locales/pt/settings/teams.json @@ -0,0 +1,16 @@ +{ + "title": "Equipes", + "team": "Equipe", + "teams": "Equipes", + "name": "Nome", + "created": "Criado", + "ownsBy": "Pertence a", + "edit": "Editar", + "editTeam": "Editar Equipe", + "pinTooltip": "Clique para fixar isso no menu principal", + "editTeamName": "Editar Nome da Equipe", + "updateName": "Atualizar Nome", + "namePlaceholder": "Nome", + "nameRequired": "Por favor digite um Nome", + "updateFailed": "Falha na alteração do nome da equipe!" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json index e86db311..1b06e543 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json @@ -1,35 +1,35 @@ { "taskHeader": { - "taskNamePlaceholder": "Digite sua tarefa", - "deleteTask": "Excluir tarefa" + "taskNamePlaceholder": "Digite sua Tarefa", + "deleteTask": "Deletar Tarefa" }, "taskInfoTab": { "title": "Informações", "details": { "title": "Detalhes", - "task-key": "Chave da tarefa", + "task-key": "Chave da Tarefa", "phase": "Fase", "assignees": "Responsáveis", - "due-date": "Data de vencimento", - "time-estimation": "Estimativa de tempo", + "due-date": "Data de Vencimento", + "time-estimation": "Estimativa de Tempo", "priority": "Prioridade", "labels": "Etiquetas", "billable": "Faturável", "notify": "Notificar", - "when-done-notify": "Quando concluída, notificar", - "start-date": "Data de início", - "end-date": "Data de término", - "hide-start-date": "Ocultar data de início", - "show-start-date": "Mostrar data de início", + "when-done-notify": "Quando concluído, notificar", + "start-date": "Data de Início", + "end-date": "Data de Fim", + "hide-start-date": "Ocultar Data de Início", + "show-start-date": "Mostrar Data de Início", "hours": "Horas", "minutes": "Minutos", - "progressValue": "Valor de Progresso", + "progressValue": "Valor do Progresso", "progressValueTooltip": "Definir a porcentagem de progresso (0-100%)", "progressValueRequired": "Por favor, insira um valor de progresso", "progressValueRange": "O progresso deve estar entre 0 e 100", "taskWeight": "Peso da Tarefa", "taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)", - "taskWeightRequired": "Por favor, insira um peso para a tarefa", + "taskWeightRequired": "Por favor, insira um peso da tarefa", "taskWeightRange": "O peso deve estar entre 0 e 100", "recurring": "Recorrente" }, @@ -42,14 +42,14 @@ "placeholder": "Adicionar uma descrição mais detalhada..." }, "subTasks": { - "title": "Subtarefas", - "addSubTask": "+ Adicionar subtarefa", + "title": "Sub Tarefas", + "addSubTask": "Adicionar Sub Tarefa", "addSubTaskInputPlaceholder": "Digite sua tarefa e pressione enter", - "refreshSubTasks": "Atualizar subtarefas", + "refreshSubTasks": "Atualizar Sub Tarefas", "edit": "Editar", - "delete": "Excluir", - "confirmDeleteSubTask": "Tem certeza de que deseja excluir esta subtarefa?", - "deleteSubTask": "Excluir subtarefa" + "delete": "Deletar", + "confirmDeleteSubTask": "Tem certeza de que deseja deletar esta subtarefa?", + "deleteSubTask": "Deletar Sub Tarefa" }, "dependencies": { "title": "Dependências", @@ -57,37 +57,67 @@ "blockedBy": "Bloqueado por", "searchTask": "Digite para pesquisar tarefa", "noTasksFound": "Nenhuma tarefa encontrada", - "confirmDeleteDependency": "Tem certeza de que deseja excluir?" + "confirmDeleteDependency": "Tem certeza de que deseja deletar?" }, "attachments": { "title": "Anexos", - "chooseOrDropFileToUpload": "Escolha ou arraste um arquivo para carregar", - "uploading": "Carregando..." + "chooseOrDropFileToUpload": "Escolha ou arraste um arquivo para upload", + "uploading": "Enviando..." }, "comments": { "title": "Comentários", "addComment": "+ Adicionar novo comentário", - "noComments": "Nenhum comentário ainda. Seja o primeiro a comentar!", - "delete": "Excluir", - "confirmDeleteComment": "Tem certeza de que deseja excluir este comentário?" + "noComments": "Ainda não há comentários. Seja o primeiro a comentar!", + "delete": "Deletar", + "confirmDeleteComment": "Tem certeza de que deseja deletar este comentário?", + "addCommentPlaceholder": "Adicionar um comentário...", + "cancel": "Cancelar", + "commentButton": "Comentar", + "attachFiles": "Anexar arquivos", + "addMoreFiles": "Adicionar mais arquivos", + "selectedFiles": "Arquivos Selecionados (Até 25MB, Máximo {count})", + "maxFilesError": "Você pode fazer upload de no máximo {count} arquivos", + "processFilesError": "Falha ao processar arquivos", + "addCommentError": "Por favor adicione um comentário ou anexe arquivos", + "createdBy": "Criado {time} por {user}", + "updatedTime": "Atualizado {time}" }, "searchInputPlaceholder": "Pesquisar por nome", - "pendingInvitation": "Convite pendente" + "pendingInvitation": "Convite Pendente" }, "taskTimeLogTab": { - "title": "Registro de tempo", + "title": "Registro de Tempo", "addTimeLog": "Adicionar novo registro de tempo", - "totalLogged": "Total registrado", + "totalLogged": "Total Registrado", "exportToExcel": "Exportar para Excel", - "noTimeLogsFound": "Nenhum registro de tempo encontrado" + "noTimeLogsFound": "Nenhum registro de tempo encontrado", + "timeLogForm": { + "date": "Data", + "startTime": "Hora de Início", + "endTime": "Hora de Fim", + "workDescription": "Descrição do Trabalho", + "descriptionPlaceholder": "Adicionar uma descrição", + "logTime": "Registrar tempo", + "updateTime": "Atualizar tempo", + "cancel": "Cancelar", + "selectDateError": "Por favor selecione uma data", + "selectStartTimeError": "Por favor selecione a hora de início", + "selectEndTimeError": "Por favor selecione a hora de fim", + "endTimeAfterStartError": "A hora de fim deve ser posterior à hora de início" + } }, "taskActivityLogTab": { - "title": "Registro de atividade" + "title": "Registro de Atividade", + "add": "ADICIONAR", + "remove": "REMOVER", + "none": "Nenhum", + "weight": "Peso", + "createdTask": "criou a tarefa." }, "taskProgress": { "markAsDoneTitle": "Marcar Tarefa como Concluída?", "confirmMarkAsDone": "Sim, marcar como concluída", "cancelMarkAsDone": "Não, manter status atual", - "markAsDoneDescription": "Você definiu o progresso como 100%. Deseja atualizar o status da tarefa para \"Concluída\"?" + "markAsDoneDescription": "Você definiu o progresso para 100%. Gostaria de atualizar o status da tarefa para \"Concluída\"?" } } diff --git a/worklenz-frontend/public/locales/pt/task-list-table.json b/worklenz-frontend/public/locales/pt/task-list-table.json index a493fcf0..b7f90398 100644 --- a/worklenz-frontend/public/locales/pt/task-list-table.json +++ b/worklenz-frontend/public/locales/pt/task-list-table.json @@ -68,6 +68,13 @@ "dueDatePlaceholder": "Data de vencimento", "startDatePlaceholder": "Data de início", + "emptyStates": { + "noTaskGroups": "Nenhum grupo de tarefas encontrado", + "noTaskGroupsDescription": "As tarefas aparecerão aqui quando forem criadas ou quando filtros forem aplicados.", + "errorPrefix": "Erro:", + "dragTaskFallback": "Tarefa" + }, + "customColumns": { "addCustomColumn": "Adicionar uma coluna personalizada", "customColumnHeader": "Coluna Personalizada", diff --git a/worklenz-frontend/public/locales/zh/404-page.json b/worklenz-frontend/public/locales/zh/404-page.json new file mode 100644 index 00000000..24a74b3e --- /dev/null +++ b/worklenz-frontend/public/locales/zh/404-page.json @@ -0,0 +1,4 @@ +{ + "doesNotExistText": "抱歉,您访问的页面不存在。", + "backHomeButton": "返回首页" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/account-setup.json b/worklenz-frontend/public/locales/zh/account-setup.json new file mode 100644 index 00000000..51cac1eb --- /dev/null +++ b/worklenz-frontend/public/locales/zh/account-setup.json @@ -0,0 +1,27 @@ +{ + "continue": "继续", + "setupYourAccount": "设置您的Worklenz账户。", + "organizationStepTitle": "命名您的组织", + "organizationStepLabel": "为您的Worklenz账户选择一个名称。", + "projectStepTitle": "创建您的第一个项目", + "projectStepLabel": "您现在正在做什么项目?", + "projectStepPlaceholder": "例如:营销计划", + "tasksStepTitle": "创建您的第一个任务", + "tasksStepLabel": "输入您将在其中完成的几个任务", + "tasksStepAddAnother": "添加另一个", + "emailPlaceholder": "电子邮件地址", + "invalidEmail": "请输入有效的电子邮件地址", + "or": "或", + "templateButton": "从模板导入", + "goBack": "返回", + "cancel": "取消", + "create": "创建", + "templateDrawerTitle": "从模板中选择", + "step3InputLabel": "通过电子邮件邀请", + "addAnother": "添加另一个", + "skipForNow": "暂时跳过", + "formTitle": "创建您的第一个任务。", + "step3Title": "邀请您的团队一起工作", + "maxMembers": "(您最多可以邀请5名成员)", + "maxTasks": "(您最多可以创建5个任务)" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/admin-center/current-bill.json b/worklenz-frontend/public/locales/zh/admin-center/current-bill.json new file mode 100644 index 00000000..e18e8761 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/admin-center/current-bill.json @@ -0,0 +1,96 @@ +{ + "title": "账单", + "currentBill": "当前账单", + "configuration": "配置", + "currentPlanDetails": "当前计划详情", + "upgradePlan": "升级计划", + "cardBodyText01": "免费试用", + "cardBodyText02": "(您的试用计划将在1个月19天后到期)", + "redeemCode": "兑换码", + "accountStorage": "账户存储", + "used": "已用:", + "remaining": "剩余:", + "charges": "费用", + "tooltip": "当前账单周期的费用", + "description": "描述", + "billingPeriod": "账单周期", + "billStatus": "账单状态", + "perUserValue": "每用户费用", + "users": "用户", + "amount": "金额", + "invoices": "发票", + "transactionId": "交易ID", + "transactionDate": "交易日期", + "paymentMethod": "支付方式", + "status": "状态", + "ltdUsers": "您最多可以添加{{ltd_users}}名用户。", + "totalSeats": "总席位", + "availableSeats": "可用席位", + "addMoreSeats": "添加更多席位", + "drawerTitle": "兑换码", + "label": "兑换码", + "drawerPlaceholder": "输入您的兑换码", + "redeemSubmit": "提交", + "modalTitle": "为您的团队选择最佳计划", + "seatLabel": "席位数量", + "freePlan": "免费计划", + "startup": "初创", + "business": "商业", + "tag": "最受欢迎", + "enterprise": "企业", + "freeSubtitle": "永远免费", + "freeUsers": "最适合个人使用", + "freeText01": "100MB存储", + "freeText02": "3个项目", + "freeText03": "5名团队成员", + "startupSubtitle": "固定费率/月", + "startupUsers": "最多15名用户", + "startupText01": "25GB存储", + "startupText02": "无限活跃项目", + "startupText03": "日程", + "startupText04": "报告", + "startupText05": "订阅项目", + "businessSubtitle": "每用户/月", + "businessUsers": "16 - 200名用户", + "enterpriseUsers": "200 - 500+名用户", + "footerTitle": "请提供一个我们可以联系您的电话号码。", + "footerLabel": "联系电话", + "footerButton": "联系我们", + "redeemCodePlaceHolder": "输入您的兑换码", + "submit": "提交", + "trialPlan": "免费试用", + "trialExpireDate": "有效期至{{trial_expire_date}}", + "trialExpired": "您的免费试用已于{{trial_expire_string}}到期", + "trialInProgress": "您的免费试用将在{{trial_expire_string}}到期", + "required": "此字段为必填项", + "invalidCode": "无效的代码", + "selectPlan": "为您的团队选择最佳计划", + "changeSubscriptionPlan": "更改您的订阅计划", + "noOfSeats": "席位数量", + "annualPlan": "专业 - 年度", + "monthlyPlan": "专业 - 月度", + "freeForever": "永远免费", + "bestForPersonalUse": "最适合个人使用", + "storage": "存储", + "projects": "项目", + "teamMembers": "团队成员", + "unlimitedTeamMembers": "无限团队成员", + "unlimitedActiveProjects": "无限活跃项目", + "schedule": "日程", + "reporting": "报告", + "subscribeToProjects": "订阅项目", + "billedAnnually": "按年计费", + "billedMonthly": "按月计费", + "pausePlan": "暂停计划", + "resumePlan": "恢复计划", + "changePlan": "更改计划", + "cancelPlan": "取消计划", + "perMonthPerUser": "每用户/月", + "viewInvoice": "查看发票", + "switchToFreePlan": "切换到免费计划", + "expirestoday": "今天", + "expirestomorrow": "明天", + "expiredDaysAgo": "{{days}}天前", + "continueWith": "继续使用{{plan}}", + "changeToPlan": "更改为{{plan}}" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/admin-center/overview.json b/worklenz-frontend/public/locales/zh/admin-center/overview.json new file mode 100644 index 00000000..9c70093f --- /dev/null +++ b/worklenz-frontend/public/locales/zh/admin-center/overview.json @@ -0,0 +1,8 @@ +{ + "overview": "概览", + "name": "组织名称", + "owner": "组织所有者", + "admins": "组织管理员", + "contactNumber": "添加联系电话", + "edit": "编辑" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/admin-center/projects.json b/worklenz-frontend/public/locales/zh/admin-center/projects.json new file mode 100644 index 00000000..ca2eded2 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/admin-center/projects.json @@ -0,0 +1,12 @@ +{ + "membersCount": "成员数量", + "createdAt": "创建于", + "projectName": "项目名称", + "teamName": "团队名称", + "refreshProjects": "刷新项目", + "searchPlaceholder": "按项目名称搜索", + "deleteProject": "您确定要删除此项目吗?", + "confirm": "确认", + "cancel": "取消", + "delete": "删除项目" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/admin-center/sidebar.json b/worklenz-frontend/public/locales/zh/admin-center/sidebar.json new file mode 100644 index 00000000..ab8808c3 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/admin-center/sidebar.json @@ -0,0 +1,8 @@ +{ + "overview": "概览", + "users": "用户", + "teams": "团队", + "billing": "账单", + "projects": "项目", + "adminCenter": "管理中心" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/admin-center/teams.json b/worklenz-frontend/public/locales/zh/admin-center/teams.json new file mode 100644 index 00000000..4244d848 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/admin-center/teams.json @@ -0,0 +1,33 @@ +{ + "title": "团队", + "subtitle": "团队", + "tooltip": "刷新团队", + "placeholder": "按名称搜索", + "addTeam": "添加团队", + "team": "团队", + "membersCount": "成员数量", + "members": "成员", + "drawerTitle": "创建新团队", + "label": "团队名称", + "drawerPlaceholder": "名称", + "create": "创建", + "delete": "删除", + "settings": "设置", + "popTitle": "您确定吗?", + "message": "请输入名称", + "teamSettings": "团队设置", + "teamName": "团队名称", + "teamDescription": "团队描述", + "teamMembers": "团队成员", + "teamMembersCount": "团队成员数量", + "teamMembersPlaceholder": "按名称搜索", + "addMember": "添加成员", + "add": "添加", + "update": "更新", + "teamNamePlaceholder": "团队名称", + "user": "用户", + "role": "角色", + "owner": "所有者", + "admin": "管理员", + "member": "成员" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/admin-center/users.json b/worklenz-frontend/public/locales/zh/admin-center/users.json new file mode 100644 index 00000000..83800c09 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/admin-center/users.json @@ -0,0 +1,9 @@ +{ + "title": "用户", + "subTitle": "用户", + "placeholder": "按名称搜索", + "user": "用户", + "email": "电子邮件", + "lastActivity": "最后活动", + "refresh": "刷新用户" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/all-project-list.json b/worklenz-frontend/public/locales/zh/all-project-list.json new file mode 100644 index 00000000..9ff1a707 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/all-project-list.json @@ -0,0 +1,23 @@ +{ + "name": "名称", + "client": "客户", + "category": "类别", + "status": "状态", + "tasksProgress": "任务进度", + "updated_at": "最后更新", + "members": "成员", + "setting": "设置", + "projects": "项目", + "refreshProjects": "刷新项目", + "all": "全部", + "favorites": "收藏", + "archived": "已归档", + "placeholder": "按名称搜索", + "archive": "归档", + "unarchive": "取消归档", + "archiveConfirm": "您确定要归档此项目吗?", + "unarchiveConfirm": "您确定要取消归档此项目吗?", + "clickToFilter": "点击以筛选", + "noProjects": "未找到项目", + "addToFavourites": "添加到收藏" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/auth/auth-common.json b/worklenz-frontend/public/locales/zh/auth/auth-common.json new file mode 100644 index 00000000..df57a70d --- /dev/null +++ b/worklenz-frontend/public/locales/zh/auth/auth-common.json @@ -0,0 +1,5 @@ +{ + "loggingOut": "正在登出...", + "authenticating": "正在认证...", + "gettingThingsReady": "正在为您准备..." +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/auth/forgot-password.json b/worklenz-frontend/public/locales/zh/auth/forgot-password.json new file mode 100644 index 00000000..de1529a4 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/auth/forgot-password.json @@ -0,0 +1,12 @@ +{ + "headerDescription": "重置您的密码", + "emailLabel": "电子邮件", + "emailPlaceholder": "输入您的电子邮件", + "emailRequired": "请输入您的电子邮件!", + "resetPasswordButton": "重置密码", + "returnToLoginButton": "返回登录", + "passwordResetSuccessMessage": "密码重置链接已发送到您的电子邮件。", + "orText": "或", + "successTitle": "重置指令已发送!", + "successMessage": "重置信息已发送到您的电子邮件。请检查您的电子邮件。" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/auth/login.json b/worklenz-frontend/public/locales/zh/auth/login.json new file mode 100644 index 00000000..e53d5fc5 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/auth/login.json @@ -0,0 +1,27 @@ +{ + "headerDescription": "登录到您的账户", + "emailLabel": "电子邮件", + "emailPlaceholder": "输入您的电子邮件", + "emailRequired": "请输入您的电子邮件!", + "passwordLabel": "密码", + "passwordPlaceholder": "输入您的密码", + "passwordRequired": "请输入您的密码!", + "rememberMe": "记住我", + "loginButton": "登录", + "signupButton": "注册", + "forgotPasswordButton": "忘记密码?", + "signInWithGoogleButton": "使用Google登录", + "dontHaveAccountText": "没有账户?", + "orText": "或", + "successMessage": "您已成功登录!", + "loginError": "登录失败", + "googleLoginError": "Google登录失败", + "validationMessages": { + "email": "请输入有效的电子邮件地址", + "password": "密码必须至少包含8个字符" + }, + "errorMessages": { + "loginErrorTitle": "登录失败", + "loginErrorMessage": "请检查您的电子邮件和密码并重试" + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/auth/signup.json b/worklenz-frontend/public/locales/zh/auth/signup.json new file mode 100644 index 00000000..a2b34e57 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/auth/signup.json @@ -0,0 +1,29 @@ +{ + "headerDescription": "注册以开始使用", + "nameLabel": "全名", + "namePlaceholder": "输入您的全名", + "nameRequired": "请输入您的全名!", + "nameMinCharacterRequired": "全名必须至少包含4个字符!", + "emailLabel": "电子邮件", + "emailPlaceholder": "输入您的电子邮件", + "emailRequired": "请输入您的电子邮件!", + "passwordLabel": "密码", + "passwordPlaceholder": "输入您的密码", + "passwordRequired": "请输入您的密码!", + "passwordMinCharacterRequired": "密码必须至少包含8个字符!", + "passwordPatternRequired": "密码不符合要求!", + "strongPasswordPlaceholder": "输入更强的密码", + "passwordValidationAltText": "密码必须至少包含8个字符,包括大小写字母、一个数字和一个符号。", + "signupSuccessMessage": "您已成功注册!", + "privacyPolicyLink": "隐私政策", + "termsOfUseLink": "使用条款", + "bySigningUpText": "通过注册,您同意我们的", + "andText": "和", + "signupButton": "注册", + "signInWithGoogleButton": "使用Google登录", + "alreadyHaveAccountText": "已经有账户了?", + "loginButton": "登录", + "orText": "或", + "reCAPTCHAVerificationError": "reCAPTCHA验证错误", + "reCAPTCHAVerificationErrorMessage": "我们无法验证您的reCAPTCHA。请重试。" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/auth/verify-reset-email.json b/worklenz-frontend/public/locales/zh/auth/verify-reset-email.json new file mode 100644 index 00000000..11222523 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/auth/verify-reset-email.json @@ -0,0 +1,14 @@ +{ + "title": "验证重置电子邮件", + "description": "输入您的新密码", + "placeholder": "输入您的新密码", + "confirmPasswordPlaceholder": "确认您的新密码", + "passwordHint": "至少8个字符,包括大小写字母、一个数字和一个符号。", + "resetPasswordButton": "重置密码", + "orText": "或", + "resendResetEmail": "重新发送重置电子邮件", + "passwordRequired": "请输入您的新密码", + "returnToLoginButton": "返回登录", + "confirmPasswordRequired": "请确认您的新密码", + "passwordMismatch": "两次输入的密码不匹配" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/common.json b/worklenz-frontend/public/locales/zh/common.json new file mode 100644 index 00000000..520ee5e2 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/common.json @@ -0,0 +1,9 @@ +{ + "login-success": "登录成功!", + "login-failed": "登录失败。请检查您的凭据并重试。", + "signup-success": "注册成功!欢迎加入。", + "signup-failed": "注册失败。请确保填写所有必填字段并重试。", + "reconnecting": "与服务器断开连接。", + "connection-lost": "无法连接到服务器。请检查您的互联网连接。", + "connection-restored": "成功连接到服务器" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/create-first-project-form.json b/worklenz-frontend/public/locales/zh/create-first-project-form.json new file mode 100644 index 00000000..95ea4099 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/create-first-project-form.json @@ -0,0 +1,13 @@ +{ + "formTitle": "创建您的第一个项目", + "inputLabel": "您现在正在做什么项目?", + "or": "或", + "templateButton": "从模板导入", + "createFromTemplate": "从模板创建", + "goBack": "返回", + "continue": "继续", + "cancel": "取消", + "create": "创建", + "templateDrawerTitle": "从模板中选择", + "createProject": "创建项目" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/create-first-tasks.json b/worklenz-frontend/public/locales/zh/create-first-tasks.json new file mode 100644 index 00000000..810d5aff --- /dev/null +++ b/worklenz-frontend/public/locales/zh/create-first-tasks.json @@ -0,0 +1,7 @@ +{ + "formTitle": "创建您的第一个任务。", + "inputLable": "输入您将在其中完成的几个任务", + "addAnother": "添加另一个", + "goBack": "返回", + "continue": "继续" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/home.json b/worklenz-frontend/public/locales/zh/home.json new file mode 100644 index 00000000..184b4f1a --- /dev/null +++ b/worklenz-frontend/public/locales/zh/home.json @@ -0,0 +1,46 @@ +{ + "todoList": { + "title": "待办事项列表", + "refreshTasks": "刷新任务", + "addTask": "+ 添加任务", + "noTasks": "没有任务", + "pressEnter": "按", + "toCreate": "创建。", + "markAsDone": "标记为完成" + }, + "projects": { + "title": "项目", + "refreshProjects": "刷新项目", + "noRecentProjects": "您当前未被分配到任何项目。", + "noFavouriteProjects": "没有项目被标记为收藏。", + "recent": "最近", + "favourites": "收藏" + }, + "tasks": { + "assignedToMe": "分配给我", + "assignedByMe": "由我分配", + "all": "全部", + "today": "今天", + "upcoming": "即将到来", + "overdue": "逾期", + "noDueDate": "没有截止日期", + "noTasks": "没有任务可显示。", + "addTask": "+ 添加任务", + "name": "名称", + "project": "项目", + "status": "状态", + "dueDate": "截止日期", + "dueDatePlaceholder": "设置截止日期", + "tomorrow": "明天", + "nextWeek": "下周", + "nextMonth": "下个月", + "projectRequired": "请选择一个项目", + "pressTabToSelectDueDateAndProject": "按Tab键选择截止日期和项目", + "dueOn": "任务截止于", + "taskRequired": "请添加一个任务", + "list": "列表", + "calendar": "日历", + "tasks": "任务", + "refresh": "刷新" + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/invite-initial-team-members.json b/worklenz-frontend/public/locales/zh/invite-initial-team-members.json new file mode 100644 index 00000000..6ebb9fbf --- /dev/null +++ b/worklenz-frontend/public/locales/zh/invite-initial-team-members.json @@ -0,0 +1,8 @@ +{ + "formTitle": "邀请您的团队一起工作", + "inputLable": "通过电子邮件邀请", + "addAnother": "添加另一个", + "goBack": "返回", + "continue": "继续", + "skipForNow": "暂时跳过" +} \ 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 new file mode 100644 index 00000000..7b72c5d5 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/kanban-board.json @@ -0,0 +1,19 @@ +{ + "rename": "重命名", + "delete": "删除", + "addTask": "添加任务", + "addSectionButton": "添加部分", + "changeCategory": "更改类别", + "deleteTooltip": "删除", + "deleteConfirmationTitle": "您确定吗?", + "deleteConfirmationOk": "是", + "deleteConfirmationCancel": "取消", + "dueDate": "截止日期", + "cancel": "取消", + "today": "今天", + "tomorrow": "明天", + "assignToMe": "分配给我", + "archive": "归档", + "newTaskNamePlaceholder": "写一个任务名称", + "newSubtaskNamePlaceholder": "写一个子任务名称" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/license-expired.json b/worklenz-frontend/public/locales/zh/license-expired.json new file mode 100644 index 00000000..838125c2 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/license-expired.json @@ -0,0 +1,6 @@ +{ + "title": "您的Worklenz试用已过期!", + "subtitle": "请立即升级。", + "button": "立即升级", + "checking": "正在检查订阅状态..." +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/navbar.json b/worklenz-frontend/public/locales/zh/navbar.json new file mode 100644 index 00000000..c4ed67ab --- /dev/null +++ b/worklenz-frontend/public/locales/zh/navbar.json @@ -0,0 +1,31 @@ +{ + "logoAlt": "Worklenz Logo", + "home": "首页", + "projects": "项目", + "schedule": "日程", + "reporting": "报告", + "clients": "客户", + "teams": "团队", + "labels": "标签", + "jobTitles": "职位", + "upgradePlan": "升级计划", + "upgradePlanTooltip": "升级计划", + "invite": "邀请", + "inviteTooltip": "邀请团队成员加入", + "switchTeamTooltip": "切换团队", + "help": "帮助", + "notificationTooltip": "查看通知", + "profileTooltip": "查看个人资料", + "adminCenter": "管理中心", + "settings": "设置", + "logOut": "登出", + "notificationsDrawer": { + "read": "已读通知", + "unread": "未读通知", + "markAsRead": "标记为已读", + "readAndJoin": "阅读并加入", + "accept": "接受", + "acceptAndJoin": "接受并加入", + "noNotifications": "没有通知" + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/organization-name-form.json b/worklenz-frontend/public/locales/zh/organization-name-form.json new file mode 100644 index 00000000..df8727d8 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/organization-name-form.json @@ -0,0 +1,5 @@ +{ + "nameYourOrganization": "命名您的组织。", + "worklenzAccountTitle": "为您的Worklenz账户选择一个名称。", + "continue": "继续" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/phases-drawer.json b/worklenz-frontend/public/locales/zh/phases-drawer.json new file mode 100644 index 00000000..4bfb2a13 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/phases-drawer.json @@ -0,0 +1,7 @@ +{ + "configurePhases": "配置阶段", + "phaseLabel": "阶段标签", + "enterPhaseName": "输入阶段标签名称", + "addOption": "添加选项", + "phaseOptions": "阶段选项:" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/project-drawer.json b/worklenz-frontend/public/locales/zh/project-drawer.json new file mode 100644 index 00000000..1649dfde --- /dev/null +++ b/worklenz-frontend/public/locales/zh/project-drawer.json @@ -0,0 +1,42 @@ +{ + "createProject": "创建项目", + "editProject": "编辑项目", + "enterCategoryName": "输入类别名称", + "hitEnterToCreate": "按回车键创建!", + "enterNotes": "备注", + "youCanManageClientsUnderSettings": "您可以在设置中管理客户", + "addCategory": "向项目添加类别", + "newCategory": "新类别", + "notes": "备注", + "startDate": "开始日期", + "endDate": "结束日期", + "estimateWorkingDays": "估算工作日", + "estimateManDays": "估算人天", + "hoursPerDay": "每天小时数", + "create": "创建", + "update": "更新", + "delete": "删除", + "typeToSearchClients": "输入以搜索客户", + "projectColor": "项目颜色", + "pleaseEnterAName": "请输入名称", + "enterProjectName": "输入项目名称", + "name": "名称", + "status": "状态", + "health": "健康状况", + "category": "类别", + "projectManager": "项目经理", + "client": "客户", + "deleteConfirmation": "您确定要删除吗?", + "deleteConfirmationDescription": "这将删除所有相关数据且无法撤销。", + "yes": "是", + "no": "否", + "createdAt": "创建于", + "updatedAt": "更新于", + "by": "由", + "add": "添加", + "asClient": "作为客户", + "createClient": "创建客户", + "searchInputPlaceholder": "按名称或电子邮件搜索", + "hoursPerDayValidationMessage": "每天小时数必须是1到24之间的数字", + "noPermission": "无权限" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/project-view-files.json b/worklenz-frontend/public/locales/zh/project-view-files.json new file mode 100644 index 00000000..9cbf8ef6 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/project-view-files.json @@ -0,0 +1,14 @@ +{ + "nameColumn": "名称", + "attachedTaskColumn": "附加任务", + "sizeColumn": "大小", + "uploadedByColumn": "上传者", + "uploadedAtColumn": "上传时间", + "fileIconAlt": "文件图标", + "titleDescriptionText": "此项目中任务的所有附件将显示在这里。", + "deleteConfirmationTitle": "您确定吗?", + "deleteConfirmationOk": "是", + "deleteConfirmationCancel": "取消", + "segmentedTooltip": "即将推出!在列表视图和缩略图视图之间切换。", + "emptyText": "项目中没有附件。" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/project-view-insights.json b/worklenz-frontend/public/locales/zh/project-view-insights.json new file mode 100644 index 00000000..903d73d2 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/project-view-insights.json @@ -0,0 +1,41 @@ +{ + "overview": { + "title": "概览", + "statusOverview": "状态概览", + "priorityOverview": "优先级概览", + "lastUpdatedTasks": "最近更新的任务" + }, + "members": { + "title": "成员", + "tooltip": "成员", + "tasksByMembers": "按成员分类任务", + "tasksByMembersTooltip": "按成员分类任务", + "name": "名称", + "taskCount": "任务计数", + "contribution": "贡献", + "completed": "已完成", + "incomplete": "未完成", + "overdue": "逾期", + "progress": "进度" + }, + "tasks": { + "overdueTasks": "逾期任务", + "overLoggedTasks": "超额记录任务", + "tasksCompletedEarly": "提前完成的任务", + "tasksCompletedLate": "延迟完成的任务", + "overLoggedTasksTooltip": "记录时间超过预计时间的任务", + "overdueTasksTooltip": "超过截止日期的任务" + }, + "common": { + "seeAll": "查看全部", + "totalLoggedHours": "总记录小时数", + "totalEstimation": "总估算", + "completedTasks": "已完成任务", + "incompleteTasks": "未完成任务", + "overdueTasks": "逾期任务", + "overdueTasksTooltip": "超过截止日期的任务", + "totalLoggedHoursTooltip": "任务估算和任务记录时间。", + "includeArchivedTasks": "包含已归档任务", + "export": "导出" + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/project-view-members.json b/worklenz-frontend/public/locales/zh/project-view-members.json new file mode 100644 index 00000000..3d217694 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/project-view-members.json @@ -0,0 +1,17 @@ +{ + "nameColumn": "名称", + "jobTitleColumn": "职位", + "emailColumn": "电子邮件", + "tasksColumn": "任务", + "taskProgressColumn": "任务进度", + "accessColumn": "访问权限", + "fileIconAlt": "文件图标", + "deleteConfirmationTitle": "您确定吗?", + "deleteConfirmationOk": "是", + "deleteConfirmationCancel": "取消", + "refreshButtonTooltip": "刷新成员", + "deleteButtonTooltip": "从项目中移除", + "memberCount": "成员", + "membersCountPlural": "成员", + "emptyText": "项目中没有附件。" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/project-view-updates.json b/worklenz-frontend/public/locales/zh/project-view-updates.json new file mode 100644 index 00000000..b34c71ea --- /dev/null +++ b/worklenz-frontend/public/locales/zh/project-view-updates.json @@ -0,0 +1,6 @@ +{ + "inputPlaceholder": "添加评论", + "addButton": "添加", + "cancelButton": "取消", + "deleteButton": "删除" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/project-view.json b/worklenz-frontend/public/locales/zh/project-view.json new file mode 100644 index 00000000..ff756ea5 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/project-view.json @@ -0,0 +1,14 @@ +{ + "taskList": "任务列表", + "board": "看板", + "insights": "数据洞察", + "files": "文件", + "members": "成员", + "updates": "动态更新", + "projectView": "项目视图", + "loading": "正在加载项目...", + "error": "加载项目时出错", + "pinnedTab": "已固定为默认标签页", + "pinTab": "固定为默认标签页", + "unpinTab": "取消固定默认标签页" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/project-view/import-task-templates.json b/worklenz-frontend/public/locales/zh/project-view/import-task-templates.json new file mode 100644 index 00000000..3dae9403 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/project-view/import-task-templates.json @@ -0,0 +1,11 @@ +{ + "importTaskTemplate": "导入任务模板", + "templateName": "模板名称", + "templateDescription": "模板描述", + "selectedTasks": "已选任务", + "tasks": "任务", + "templates": "模板", + "remove": "移除", + "cancel": "取消", + "import": "导入" +} \ No newline at end of file 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 new file mode 100644 index 00000000..f412f22b --- /dev/null +++ b/worklenz-frontend/public/locales/zh/project-view/project-member-drawer.json @@ -0,0 +1,7 @@ +{ + "title": "项目成员", + "searchLabel": "通过添加名称或电子邮件添加成员", + "searchPlaceholder": "输入名称或电子邮件", + "inviteAsAMember": "邀请为成员", + "inviteNewMemberByEmail": "通过电子邮件邀请新成员" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/project-view/project-view-header.json b/worklenz-frontend/public/locales/zh/project-view/project-view-header.json new file mode 100644 index 00000000..ca0ead5c --- /dev/null +++ b/worklenz-frontend/public/locales/zh/project-view/project-view-header.json @@ -0,0 +1,29 @@ +{ + "importTasks": "导入任务", + "importTask": "导入任务", + "createTask": "创建任务", + "settings": "设置", + "subscribe": "订阅", + "unsubscribe": "取消订阅", + "deleteProject": "删除项目", + "startDate": "开始日期", + "endDate": "结束日期", + "projectSettings": "项目设置", + "projectSummary": "项目摘要", + "receiveProjectSummary": "每晚接收项目摘要。", + "refreshProject": "刷新项目", + "saveAsTemplate": "保存为模板", + "invite": "邀请", + "subscribeTooltip": "订阅项目通知", + "unsubscribeTooltip": "取消订阅项目通知", + "refreshTooltip": "刷新项目数据", + "settingsTooltip": "打开项目设置", + "saveAsTemplateTooltip": "将此项目保存为模板", + "inviteTooltip": "邀请团队成员加入此项目", + "createTaskTooltip": "创建新任务", + "importTaskTooltip": "从模板导入任务", + "navigateBackTooltip": "返回项目列表", + "projectStatusTooltip": "项目状态", + "projectDatesInfo": "项目时间安排信息", + "projectCategoryTooltip": "项目类别" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/project-view/save-as-template.json b/worklenz-frontend/public/locales/zh/project-view/save-as-template.json new file mode 100644 index 00000000..d1d3dfa8 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/project-view/save-as-template.json @@ -0,0 +1,27 @@ +{ + "title": "保存为模板", + "templateName": "模板名称", + "includes": "项目中应包含哪些内容到模板中?", + "includesOptions": { + "statuses": "状态", + "phases": "阶段", + "labels": "标签" + }, + "taskIncludes": "任务中应包含哪些内容到模板中?", + "taskIncludesOptions": { + "statuses": "状态", + "phases": "阶段", + "labels": "标签", + "name": "名称", + "priority": "优先级", + "status": "状态", + "phase": "阶段", + "label": "标签", + "timeEstimate": "预计用时", + "description": "描述", + "subTasks": "子任务" + }, + "cancel": "取消", + "save": "保存", + "templateNamePlaceholder": "输入模板名称" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/reporting-members-drawer.json b/worklenz-frontend/public/locales/zh/reporting-members-drawer.json new file mode 100644 index 00000000..db42a74b --- /dev/null +++ b/worklenz-frontend/public/locales/zh/reporting-members-drawer.json @@ -0,0 +1,76 @@ +{ + "exportButton": "导出", + "timeLogsButton": "时间日志", + "activityLogsButton": "活动日志", + "tasksButton": "任务", + "searchByNameInputPlaceholder": "按名称搜索", + "overviewTab": "概览", + "timeLogsTab": "时间日志", + "activityLogsTab": "活动日志", + "tasksTab": "任务", + "projectsText": "项目", + "totalTasksText": "任务总数", + "assignedTasksText": "已分配任务", + "completedTasksText": "已完成任务", + "ongoingTasksText": "进行中任务", + "overdueTasksText": "逾期任务", + "loggedHoursText": "记录小时数", + "tasksText": "任务", + "allText": "全部", + "tasksByProjectsText": "按项目分类任务", + "tasksByStatusText": "按状态分类任务", + "tasksByPriorityText": "按优先级分类任务", + "todoText": "待办", + "doingText": "进行中", + "doneText": "已完成", + "lowText": "低", + "mediumText": "中", + "highText": "高", + "billableButton": "可计费", + "billableText": "可计费", + "nonBillableText": "不可计费", + "timeLogsEmptyPlaceholder": "没有时间日志可显示", + "loggedText": "记录", + "forText": "为", + "inText": "在", + "updatedText": "更新", + "fromText": "从", + "toText": "到", + "withinText": "在...之内", + "activityLogsEmptyPlaceholder": "没有活动日志可显示", + "filterByText": "筛选依据:", + "selectProjectPlaceholder": "选择项目", + "taskColumn": "任务", + "nameColumn": "名称", + "projectColumn": "项目", + "statusColumn": "状态", + "priorityColumn": "优先级", + "dueDateColumn": "截止日期", + "completedDateColumn": "完成日期", + "estimatedTimeColumn": "预计用时", + "loggedTimeColumn": "记录时间", + "overloggedTimeColumn": "超额记录时间", + "daysLeftColumn": "剩余天数/逾期", + "startDateColumn": "开始日期", + "endDateColumn": "结束日期", + "actualTimeColumn": "实际时间", + "projectHealthColumn": "项目健康状况", + "categoryColumn": "类别", + "projectManagerColumn": "项目经理", + "tasksStatsOverviewDrawerTitle": "的任务", + "projectsStatsOverviewDrawerTitle": "的项目", + "cancelledText": "已取消", + "blockedText": "已阻塞", + "onHoldText": "暂停", + "proposedText": "提议", + "inPlanningText": "规划中", + "inProgressText": "进行中", + "completedText": "已完成", + "continuousText": "持续", + "daysLeftText": "天剩余", + "daysOverdueText": "天逾期", + "notSetText": "未设置", + "needsAttentionText": "需要关注", + "atRiskText": "有风险", + "goodText": "良好" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/reporting-members.json b/worklenz-frontend/public/locales/zh/reporting-members.json new file mode 100644 index 00000000..de4c23bb --- /dev/null +++ b/worklenz-frontend/public/locales/zh/reporting-members.json @@ -0,0 +1,31 @@ +{ + "yesterdayText": "昨天", + "lastSevenDaysText": "过去7天", + "lastWeekText": "上周", + "lastThirtyDaysText": "过去30天", + "lastMonthText": "上个月", + "lastThreeMonthsText": "过去3个月", + "allTimeText": "所有时间", + "customRangeText": "自定义范围", + "startDateInputPlaceholder": "开始日期", + "EndDateInputPlaceholder": "结束日期", + "filterButton": "筛选", + "membersTitle": "成员", + "includeArchivedButton": "包含已归档项目", + "exportButton": "导出", + "excelButton": "Excel", + "searchByNameInputPlaceholder": "按名称搜索", + "memberColumn": "成员", + "tasksProgressColumn": "任务进度", + "tasksAssignedColumn": "分配任务", + "completedTasksColumn": "已完成任务", + "overdueTasksColumn": "逾期任务", + "ongoingTasksColumn": "进行中任务", + "tasksAssignedColumnTooltip": "在选定日期范围内分配的任务", + "overdueTasksColumnTooltip": "在选定日期范围结束时逾期的任务", + "completedTasksColumnTooltip": "在选定日期范围内完成的任务", + "ongoingTasksColumnTooltip": "已开始但尚未完成的任务", + "todoText": "待办", + "doingText": "进行中", + "doneText": "已完成" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/reporting-overview-drawer.json b/worklenz-frontend/public/locales/zh/reporting-overview-drawer.json new file mode 100644 index 00000000..a02b318f --- /dev/null +++ b/worklenz-frontend/public/locales/zh/reporting-overview-drawer.json @@ -0,0 +1,33 @@ +{ + "exportButton": "导出", + "projectsButton": "项目", + "membersButton": "成员", + "searchByNameInputPlaceholder": "按名称搜索", + "overviewTab": "概览", + "projectsTab": "项目", + "membersTab": "成员", + "projectsByStatusText": "按状态分类项目", + "projectsByCategoryText": "按类别分类项目", + "projectsByHealthText": "按健康状况分类项目", + "projectsText": "项目", + "allText": "全部", + "cancelledText": "已取消", + "blockedText": "已阻塞", + "onHoldText": "暂停", + "proposedText": "提议", + "inPlanningText": "规划中", + "inProgressText": "进行中", + "completedText": "已完成", + "continuousText": "持续", + "notSetText": "未设置", + "needsAttentionText": "需要关注", + "atRiskText": "有风险", + "goodText": "良好", + "nameColumn": "名称", + "emailColumn": "电子邮件", + "projectsColumn": "项目", + "tasksColumn": "任务", + "overdueTasksColumn": "逾期任务", + "completedTasksColumn": "已完成任务", + "ongoingTasksColumn": "进行中任务" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/reporting-overview.json b/worklenz-frontend/public/locales/zh/reporting-overview.json new file mode 100644 index 00000000..fb172817 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/reporting-overview.json @@ -0,0 +1,22 @@ +{ + "overviewTitle": "概览", + "includeArchivedButton": "包含已归档项目", + "teamCount": "团队", + "teamCountPlural": "团队", + "projectCount": "项目", + "projectCountPlural": "项目", + "memberCount": "成员", + "memberCountPlural": "成员", + "activeProjectCount": "活跃项目", + "activeProjectCountPlural": "活跃项目", + "overdueProjectCount": "逾期项目", + "overdueProjectCountPlural": "逾期项目", + "unassignedMemberCount": "未分配成员", + "unassignedMemberCountPlural": "未分配成员", + "memberWithOverdueTaskCount": "有逾期任务的成员", + "memberWithOverdueTaskCountPlural": "有逾期任务的成员", + "teamsText": "团队", + "nameColumn": "名称", + "projectsColumn": "项目", + "membersColumn": "成员" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/reporting-projects-drawer.json b/worklenz-frontend/public/locales/zh/reporting-projects-drawer.json new file mode 100644 index 00000000..d2f2f6ef --- /dev/null +++ b/worklenz-frontend/public/locales/zh/reporting-projects-drawer.json @@ -0,0 +1,52 @@ +{ + "exportButton": "导出", + "membersButton": "成员", + "tasksButton": "任务", + "searchByNameInputPlaceholder": "按名称搜索", + "overviewTab": "概览", + "membersTab": "成员", + "tasksTab": "任务", + "completedTasksText": "已完成任务", + "incompleteTasksText": "未完成任务", + "overdueTasksText": "逾期任务", + "allocatedHoursText": "已分配小时数", + "loggedHoursText": "已记录小时数", + "tasksText": "任务", + "allText": "全部", + "tasksByStatusText": "按状态分类任务", + "tasksByPriorityText": "按优先级分类任务", + "tasksByDueDateText": "按截止日期分类任务", + "todoText": "待办", + "doingText": "进行中", + "doneText": "已完成", + "lowText": "低", + "mediumText": "中", + "highText": "高", + "completedText": "已完成", + "upcomingText": "即将到来", + "overdueText": "逾期", + "noDueDateText": "无截止日期", + "nameColumn": "名称", + "tasksCountColumn": "任务计数", + "completedTasksColumn": "已完成任务", + "incompleteTasksColumn": "未完成任务", + "overdueTasksColumn": "逾期任务", + "contributionColumn": "贡献", + "progressColumn": "进度", + "loggedTimeColumn": "记录时间", + "taskColumn": "任务", + "projectColumn": "项目", + "statusColumn": "状态", + "priorityColumn": "优先级", + "phaseColumn": "阶段", + "dueDateColumn": "截止日期", + "completedDateColumn": "完成日期", + "estimatedTimeColumn": "预计用时", + "overloggedTimeColumn": "超额记录时间", + "completedOnColumn": "完成于", + "daysOverdueColumn": "逾期天数", + "groupByText": "分组依据:", + "statusText": "状态", + "priorityText": "优先级", + "phaseText": "阶段" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/reporting-projects-filters.json b/worklenz-frontend/public/locales/zh/reporting-projects-filters.json new file mode 100644 index 00000000..ddfbe104 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/reporting-projects-filters.json @@ -0,0 +1,31 @@ +{ + "searchByNamePlaceholder": "按名称搜索", + "searchByCategoryPlaceholder": "按类别搜索", + "statusText": "状态", + "healthText": "健康状况", + "categoryText": "类别", + "projectManagerText": "项目经理", + "showFieldsText": "显示字段", + "cancelledText": "已取消", + "blockedText": "已阻塞", + "onHoldText": "暂停", + "proposedText": "提议", + "inPlanningText": "规划中", + "inProgressText": "进行中", + "completedText": "已完成", + "continuousText": "持续", + "notSetText": "未设置", + "needsAttentionText": "需要关注", + "atRiskText": "有风险", + "goodText": "良好", + "nameText": "项目", + "estimatedVsActualText": "预计用时 vs 实际用时", + "tasksProgressText": "任务进度", + "lastActivityText": "最后活动", + "datesText": "开始/结束日期", + "daysLeftText": "剩余天数/逾期", + "projectHealthText": "项目健康状况", + "projectUpdateText": "项目更新", + "clientText": "客户", + "teamText": "团队" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/reporting-projects.json b/worklenz-frontend/public/locales/zh/reporting-projects.json new file mode 100644 index 00000000..0ff7d415 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/reporting-projects.json @@ -0,0 +1,44 @@ +{ + "projectCount": "项目", + "projectCountPlural": "项目", + "includeArchivedButton": "包含已归档项目", + "exportButton": "导出", + "excelButton": "Excel", + "projectColumn": "项目", + "estimatedVsActualColumn": "预计用时 vs 实际用时", + "tasksProgressColumn": "任务进度", + "lastActivityColumn": "最后活动", + "statusColumn": "状态", + "datesColumn": "开始/结束日期", + "daysLeftColumn": "剩余天数/逾期", + "projectHealthColumn": "项目健康状况", + "categoryColumn": "类别", + "projectUpdateColumn": "项目更新", + "clientColumn": "客户", + "teamColumn": "团队", + "projectManagerColumn": "项目经理", + "openButton": "打开", + "estimatedText": "预计", + "actualText": "实际", + "todoText": "待办", + "doingText": "进行中", + "doneText": "已完成", + "cancelledText": "已取消", + "blockedText": "已阻塞", + "onHoldText": "暂停", + "proposedText": "提议", + "inPlanningText": "规划中", + "inProgressText": "进行中", + "completedText": "已完成", + "continuousText": "持续", + "daysLeftText": "天剩余", + "dayLeftText": "天剩余", + "daysOverdueText": "天逾期", + "notSetText": "未设置", + "needsAttentionText": "需要关注", + "atRiskText": "有风险", + "goodText": "良好", + "setCategoryText": "设置类别", + "searchByNameInputPlaceholder": "按名称搜索", + "todayText": "今天" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/reporting-sidebar.json b/worklenz-frontend/public/locales/zh/reporting-sidebar.json new file mode 100644 index 00000000..8a8206fb --- /dev/null +++ b/worklenz-frontend/public/locales/zh/reporting-sidebar.json @@ -0,0 +1,8 @@ +{ + "overview": "概览", + "projects": "项目", + "members": "成员", + "timeReports": "用时报告", + "estimateVsActual": "预计用时 vs 实际用时", + "currentOrganizationTooltip": "当前的组织" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/schedule.json b/worklenz-frontend/public/locales/zh/schedule.json new file mode 100644 index 00000000..53fa8a97 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/schedule.json @@ -0,0 +1,34 @@ +{ + "today": "今天", + "week": "周", + "month": "月", + "settings": "设置", + "workingDays": "工作日", + "monday": "星期一", + "tuesday": "星期二", + "wednesday": "星期三", + "thursday": "星期四", + "friday": "星期五", + "saturday": "星期六", + "sunday": "星期日", + "workingHours": "工作时间", + "hours": "小时", + "saveButton": "保存", + "totalAllocation": "总分配", + "timeLogged": "记录时间", + "remainingTime": "剩余时间", + "total": "总计", + "perDay": "每天", + "tasks": "任务", + "startDate": "开始日期", + "endDate": "结束日期", + "hoursPerDay": "每天小时数", + "totalHours": "总小时数", + "deleteButton": "删除", + "cancelButton": "取消", + "tabTitle": "没有开始和结束日期的任务", + "allocatedTime": "分配时间", + "totalLogged": "总记录", + "loggedBillable": "已记录可计费", + "loggedNonBillable": "已记录不可计费" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/settings/categories.json b/worklenz-frontend/public/locales/zh/settings/categories.json new file mode 100644 index 00000000..00027081 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/categories.json @@ -0,0 +1,10 @@ +{ + "categoryColumn": "类别", + "deleteConfirmationTitle": "您确定吗?", + "deleteConfirmationOk": "是", + "deleteConfirmationCancel": "取消", + "associatedTaskColumn": "关联项目", + "searchPlaceholder": "按名称搜索", + "emptyText": "在更新或创建项目时可以创建类别。", + "colorChangeTooltip": "点击更改颜色" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/settings/change-password.json b/worklenz-frontend/public/locales/zh/settings/change-password.json new file mode 100644 index 00000000..30cec581 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/change-password.json @@ -0,0 +1,15 @@ +{ + "title": "更改密码", + "currentPassword": "当前密码", + "newPassword": "新密码", + "confirmPassword": "确认密码", + "currentPasswordPlaceholder": "输入您的当前密码", + "newPasswordPlaceholder": "新密码", + "confirmPasswordPlaceholder": "确认密码", + "currentPasswordRequired": "请输入您的当前密码!", + "newPasswordRequired": "请输入您的新密码!", + "passwordValidationError": "密码必须至少包含8个字符,包括一个大写字母、一个数字和一个符号。", + "passwordMismatch": "密码不匹配!", + "passwordRequirements": "新密码应至少包含8个字符,包括一个大写字母、一个数字和一个符号。", + "updateButton": "更新密码" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/settings/clients.json b/worklenz-frontend/public/locales/zh/settings/clients.json new file mode 100644 index 00000000..c06b1adc --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/clients.json @@ -0,0 +1,22 @@ +{ + "nameColumn": "名称", + "projectColumn": "项目", + "noProjectsAvailable": "没有可用的项目", + "deleteConfirmationTitle": "您确定吗?", + "deleteConfirmationOk": "是", + "deleteConfirmationCancel": "取消", + "searchPlaceholder": "按名称搜索", + "createClient": "创建客户", + "pinTooltip": "点击将其固定到主菜单", + "createClientDrawerTitle": "创建客户", + "updateClientDrawerTitle": "更新客户", + "nameLabel": "名称", + "namePlaceholder": "名称", + "nameRequiredError": "请输入名称", + "createButton": "创建", + "updateButton": "更新", + "createClientSuccessMessage": "客户创建成功!", + "createClientErrorMessage": "客户创建失败!", + "updateClientSuccessMessage": "客户更新成功!", + "updateClientErrorMessage": "客户更新失败!" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/settings/job-titles.json b/worklenz-frontend/public/locales/zh/settings/job-titles.json new file mode 100644 index 00000000..c0458bb6 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/job-titles.json @@ -0,0 +1,20 @@ +{ + "nameColumn": "名称", + "deleteConfirmationTitle": "您确定吗?", + "deleteConfirmationOk": "是", + "deleteConfirmationCancel": "取消", + "searchPlaceholder": "按名称搜索", + "createJobTitleButton": "创建职位", + "pinTooltip": "点击将其固定到主菜单", + "createJobTitleDrawerTitle": "创建职位", + "updateJobTitleDrawerTitle": "更新职位", + "nameLabel": "名称", + "namePlaceholder": "名称", + "nameRequiredError": "请输入名称", + "createButton": "创建", + "updateButton": "更新", + "createJobTitleSuccessMessage": "职位创建成功!", + "createJobTitleErrorMessage": "职位创建失败!", + "updateJobTitleSuccessMessage": "职位更新成功!", + "updateJobTitleErrorMessage": "职位更新失败!" +} \ 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 new file mode 100644 index 00000000..ab0d01cd --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/labels.json @@ -0,0 +1,11 @@ +{ + "labelColumn": "标签", + "deleteConfirmationTitle": "您确定吗?", + "deleteConfirmationOk": "是", + "deleteConfirmationCancel": "取消", + "associatedTaskColumn": "关联任务计数", + "searchPlaceholder": "按名称搜索", + "emptyText": "标签可以在更新或创建任务时创建。", + "pinTooltip": "点击将其固定到主菜单", + "colorChangeTooltip": "点击更改颜色" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/settings/language.json b/worklenz-frontend/public/locales/zh/settings/language.json new file mode 100644 index 00000000..631eac11 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/language.json @@ -0,0 +1,7 @@ +{ + "language": "语言", + "language_required": "语言是必需的", + "time_zone": "时区", + "time_zone_required": "时区是必需的", + "save_changes": "保存更改" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/settings/notifications.json b/worklenz-frontend/public/locales/zh/settings/notifications.json new file mode 100644 index 00000000..f15784bf --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/notifications.json @@ -0,0 +1,11 @@ +{ + "title": "通知设置", + "emailTitle": "向我发送电子邮件通知", + "emailDescription": "包括新的任务分配", + "dailyDigestTitle": "向我发送每日摘要", + "dailyDigestDescription": "每天晚上,您将收到任务中最近活动的摘要。", + "popupTitle": "当Worklenz打开时,在我的电脑上弹出通知", + "popupDescription": "弹出通知可能会被您的浏览器禁用。更改您的浏览器设置以允许它们。", + "unreadItemsTitle": "显示未读项目的数量", + "unreadItemsDescription": "您将看到每个通知的计数。" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/settings/profile.json b/worklenz-frontend/public/locales/zh/settings/profile.json new file mode 100644 index 00000000..cfafeb12 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/profile.json @@ -0,0 +1,14 @@ +{ + "uploadError": "您只能上传JPG/PNG文件!", + "uploadSizeError": "图片必须小于2MB!", + "upload": "上传", + "nameLabel": "名称", + "nameRequiredError": "名称是必需的", + "emailLabel": "电子邮件", + "emailRequiredError": "电子邮件是必需的", + "saveChanges": "保存更改", + "profileJoinedText": "一个月前加入", + "profileLastUpdatedText": "一个月前更新", + "avatarTooltip": "点击上传头像", + "title": "个人资料设置" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/settings/project-templates.json b/worklenz-frontend/public/locales/zh/settings/project-templates.json new file mode 100644 index 00000000..5dcc866c --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/project-templates.json @@ -0,0 +1,8 @@ +{ + "nameColumn": "名称", + "editToolTip": "编辑", + "deleteToolTip": "删除", + "confirmText": "您确定吗?", + "okText": "是", + "cancelText": "取消" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/settings/sidebar.json b/worklenz-frontend/public/locales/zh/settings/sidebar.json new file mode 100644 index 00000000..b9f74709 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/sidebar.json @@ -0,0 +1,15 @@ +{ + "profile": "个人资料", + "appearance": "外观", + "notifications": "通知", + "clients": "客户", + "job-titles": "职位", + "labels": "标签", + "categories": "类别", + "project-templates": "项目模板", + "task-templates": "任务模板", + "team-members": "团队成员", + "teams": "团队", + "change-password": "更改密码", + "language-and-region": "语言和地区" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/settings/task-templates.json b/worklenz-frontend/public/locales/zh/settings/task-templates.json new file mode 100644 index 00000000..3fd9124a --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/task-templates.json @@ -0,0 +1,9 @@ +{ + "nameColumn": "名称", + "createdColumn": "创建时间", + "editToolTip": "编辑", + "deleteToolTip": "删除", + "confirmText": "您确定吗?", + "okText": "是", + "cancelText": "取消" +} \ 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 new file mode 100644 index 00000000..8b39483c --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/team-members.json @@ -0,0 +1,47 @@ +{ + "title": "团队成员", + "nameColumn": "名称", + "projectsColumn": "项目", + "emailColumn": "电子邮件", + "teamAccessColumn": "团队访问", + "memberCount": "成员", + "membersCountPlural": "成员", + "searchPlaceholder": "按名称搜索成员", + "pinTooltip": "刷新成员列表", + "addMemberButton": "添加新成员", + "editTooltip": "编辑成员", + "deactivateTooltip": "停用成员", + "activateTooltip": "激活成员", + "deleteTooltip": "删除成员", + "confirmDeleteTitle": "您确定要删除此成员吗?", + "confirmActivateTitle": "您确定要更改此成员的状态吗?", + "okText": "是,继续", + "cancelText": "否,取消", + "deactivatedText": "(当前已停用)", + "pendingInvitationText": "(邀请待处理)", + "addMemberDrawerTitle": "添加新团队成员", + "updateMemberDrawerTitle": "更新团队成员", + "addMemberEmailHint": "无论是否接受邀请,成员都将被添加到团队中", + "memberEmailLabel": "电子邮件", + "memberEmailPlaceholder": "输入团队成员的电子邮件地址", + "memberEmailRequiredError": "请输入有效的电子邮件地址", + "jobTitleLabel": "职位", + "jobTitlePlaceholder": "选择或搜索职位(可选)", + "memberAccessLabel": "访问级别", + "addToTeamButton": "将成员添加到团队", + "updateButton": "保存更改", + "resendInvitationButton": "重新发送邀请邮件", + "invitationSentSuccessMessage": "团队邀请已成功发送!", + "createMemberSuccessMessage": "新团队成员已成功添加!", + "createMemberErrorMessage": "添加团队成员失败。请重试。", + "updateMemberSuccessMessage": "团队成员已成功更新!", + "updateMemberErrorMessage": "更新团队成员失败。请重试。", + "memberText": "成员", + "adminText": "管理员", + "ownerText": "团队所有者", + "addedText": "已添加", + "updatedText": "已更新", + "noResultFound": "输入电子邮件地址并按回车键...", + "jobTitlesFetchError": "获取职位失败", + "invitationResent": "邀请重新发送成功!" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/settings/teams.json b/worklenz-frontend/public/locales/zh/settings/teams.json new file mode 100644 index 00000000..af2064ae --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/teams.json @@ -0,0 +1,16 @@ +{ + "title": "团队", + "team": "团队", + "teams": "团队", + "name": "名称", + "created": "创建时间", + "ownsBy": "所有者", + "edit": "编辑", + "editTeam": "编辑团队", + "pinTooltip": "点击将此项固定到主菜单", + "editTeamName": "编辑团队名称", + "updateName": "更新名称", + "namePlaceholder": "名称", + "nameRequired": "请输入名称", + "updateFailed": "团队名称更改失败!" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/task-drawer/task-drawer-info-tab.json b/worklenz-frontend/public/locales/zh/task-drawer/task-drawer-info-tab.json new file mode 100644 index 00000000..b0b36689 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/task-drawer/task-drawer-info-tab.json @@ -0,0 +1,29 @@ +{ + "details": { + "task-key": "任务ID", + "phase": "阶段", + "assignees": "受托人", + "due-date": "截止日期", + "time-estimation": "估计时间", + "priority": "优先级", + "labels": "标签", + "billable": "可计费", + "notify": "通知", + "when-done-notify": "完成时通知", + "start-date": "开始日期", + "end-date": "结束日期", + "hide-start-date": "隐藏开始日期", + "show-start-date": "显示开始日期", + "hours": "小时", + "minutes": "分钟" + }, + "description": { + "title": "描述", + "placeholder": "添加更详细的描述..." + }, + "subTasks": { + "title": "子任务", + "add-sub-task": "+ 添加子任务", + "refresh-sub-tasks": "刷新子任务" + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/zh/task-drawer/task-drawer.json new file mode 100644 index 00000000..66240131 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/task-drawer/task-drawer.json @@ -0,0 +1,123 @@ +{ + "taskHeader": { + "taskNamePlaceholder": "输入您的任务", + "deleteTask": "删除任务" + }, + "taskInfoTab": { + "title": "信息", + "details": { + "title": "详情", + "task-key": "任务键", + "phase": "阶段", + "assignees": "受让人", + "due-date": "截止日期", + "time-estimation": "时间估算", + "priority": "优先级", + "labels": "标签", + "billable": "可计费", + "notify": "通知", + "when-done-notify": "完成时,通知", + "start-date": "开始日期", + "end-date": "结束日期", + "hide-start-date": "隐藏开始日期", + "show-start-date": "显示开始日期", + "hours": "小时", + "minutes": "分钟", + "progressValue": "进度值", + "progressValueTooltip": "设置进度百分比(0-100%)", + "progressValueRequired": "请输入进度值", + "progressValueRange": "进度必须在0到100之间", + "taskWeight": "任务权重", + "taskWeightTooltip": "设置此子任务的权重(百分比)", + "taskWeightRequired": "请输入任务权重", + "taskWeightRange": "权重必须在0到100之间", + "recurring": "重复" + }, + "labels": { + "labelInputPlaceholder": "搜索或创建", + "labelsSelectorInputTip": "按回车创建" + }, + "description": { + "title": "描述", + "placeholder": "添加更详细的描述..." + }, + "subTasks": { + "title": "子任务", + "addSubTask": "添加子任务", + "addSubTaskInputPlaceholder": "输入您的任务并按回车", + "refreshSubTasks": "刷新子任务", + "edit": "编辑", + "delete": "删除", + "confirmDeleteSubTask": "您确定要删除此子任务吗?", + "deleteSubTask": "删除子任务" + }, + "dependencies": { + "title": "依赖关系", + "addDependency": "+ 添加新依赖", + "blockedBy": "被阻止", + "searchTask": "输入搜索任务", + "noTasksFound": "未找到任务", + "confirmDeleteDependency": "您确定要删除吗?" + }, + "attachments": { + "title": "附件", + "chooseOrDropFileToUpload": "选择或拖放文件上传", + "uploading": "上传中..." + }, + "comments": { + "title": "评论", + "addComment": "+ 添加新评论", + "noComments": "还没有评论。成为第一个评论的人!", + "delete": "删除", + "confirmDeleteComment": "您确定要删除此评论吗?", + "addCommentPlaceholder": "添加评论...", + "cancel": "取消", + "commentButton": "评论", + "attachFiles": "附加文件", + "addMoreFiles": "添加更多文件", + "selectedFiles": "已选择的文件(最多25MB,最大{count}个)", + "maxFilesError": "您最多只能上传{count}个文件", + "processFilesError": "处理文件失败", + "addCommentError": "请添加评论或附加文件", + "createdBy": "{time}由{user}创建", + "updatedTime": "更新于{time}" + }, + "searchInputPlaceholder": "按名称搜索", + "pendingInvitation": "待处理邀请" + }, + "taskTimeLogTab": { + "title": "时间日志", + "addTimeLog": "添加新时间日志", + "totalLogged": "总记录时间", + "exportToExcel": "导出到Excel", + "noTimeLogsFound": "未找到时间日志", + "timeLogForm": { + "date": "日期", + "startTime": "开始时间", + "endTime": "结束时间", + "workDescription": "工作描述", + "descriptionPlaceholder": "添加描述", + "logTime": "记录时间", + "updateTime": "更新时间", + "cancel": "取消", + "selectDateError": "请选择日期", + "selectStartTimeError": "请选择开始时间", + "selectEndTimeError": "请选择结束时间", + "endTimeAfterStartError": "结束时间必须在开始时间之后" + } + }, + "taskActivityLogTab": { + "title": "活动日志", + "add": "添加", + "remove": "移除", + "none": "无", + "weight": "权重", + "createdTask": "创建了任务。" + }, + "taskProgress": { + "markAsDoneTitle": "将任务标记为完成?", + "confirmMarkAsDone": "是的,标记为完成", + "cancelMarkAsDone": "不,保持当前状态", + "markAsDoneDescription": "您已将进度设置为100%。您想将任务状态更新为\"完成\"吗?" + } +} \ 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 new file mode 100644 index 00000000..a3354305 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/task-list-filters.json @@ -0,0 +1,66 @@ +{ + "searchButton": "搜索", + "resetButton": "重置", + "searchInputPlaceholder": "按名称搜索", + "sortText": "排序", + "statusText": "状态", + "phaseText": "阶段", + "memberText": "成员", + "assigneesText": "受托人", + "priorityText": "优先级", + "labelsText": "标签", + "membersText": "成员", + "groupByText": "分组依据", + "showArchivedText": "显示已归档的任务", + "showFieldsText": "显示字段", + "keyText": "ID", + "taskText": "任务", + "descriptionText": "描述", + "phasesText": "阶段", + "listText": "列表", + "progressText": "进度", + "timeTrackingText": "时间跟踪", + "timetrackingText": "时间跟踪", + "estimationText": "估计", + "startDateText": "开始日期", + "startdateText": "开始日期", + "endDateText": "结束日期", + "dueDateText": "截止日期", + "duedateText": "截止日期", + "completedDateText": "完成日期", + "completeddateText": "完成日期", + "createdDateText": "创建日期", + "createddateText": "创建日期", + "lastUpdatedText": "最后更新", + "lastupdatedText": "最后更新", + "reporterText": "报告人", + "dueTimeText": "截止时间", + "duetimeText": "截止时间", + "lowText": "低", + "mediumText": "中", + "highText": "高", + "createStatusButtonTooltip": "状态设置", + "configPhaseButtonTooltip": "阶段设置", + "noLabelsFound": "未找到标签", + "addStatusButton": "添加状态", + "addPhaseButton": "添加阶段", + "createStatus": "创建状态", + "name": "名称", + "category": "类别", + "selectCategory": "选择类别", + "pleaseEnterAName": "请输入名称", + "pleaseSelectACategory": "请选择类别", + "create": "创建", + "searchTasks": "搜索任务...", + "searchPlaceholder": "搜索...", + "fieldsText": "字段", + "loadingFilters": "加载筛选器...", + "noOptionsFound": "未找到选项", + "filtersActive": "个筛选器已激活", + "filterActive": "个筛选器已激活", + "clearAll": "清除全部", + "clearing": "清除中...", + "cancel": "取消", + "search": "搜索", + "groupedBy": "分组依据" +} \ 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 new file mode 100644 index 00000000..d2f9634c --- /dev/null +++ b/worklenz-frontend/public/locales/zh/task-list-table.json @@ -0,0 +1,126 @@ +{ + "keyColumn": "ID", + "taskColumn": "任务", + "descriptionColumn": "描述", + "progressColumn": "进度", + "membersColumn": "成员", + "assigneesColumn": "受托人", + "labelsColumn": "标签", + "phasesColumn": "阶段", + "phaseColumn": "阶段", + "statusColumn": "状态", + "priorityColumn": "优先级", + "timeTrackingColumn": "时间追踪", + "timetrackingColumn": "时间追踪", + "estimationColumn": "估算", + "startDateColumn": "开始日期", + "startdateColumn": "开始日期", + "dueDateColumn": "截止日期", + "duedateColumn": "截止日期", + "completedDateColumn": "完成日期", + "completeddateColumn": "完成日期", + "createdDateColumn": "创建日期", + "createddateColumn": "创建日期", + "lastUpdatedColumn": "最后更新", + "lastupdatedColumn": "最后更新", + "reporterColumn": "报告人", + "dueTimeColumn": "截止时间", + "todoSelectorText": "待办", + "doingSelectorText": "进行中", + "doneSelectorText": "已完成", + "lowSelectorText": "低", + "mediumSelectorText": "中", + "highSelectorText": "高", + "selectText": "选择", + "labelsSelectorInputTip": "按回车键创建!", + "addTaskText": "+ 添加任务", + "addSubTaskText": "+ 添加子任务", + "addTaskInputPlaceholder": "输入任务并按回车键", + "noTasksInGroup": "此组中没有任务", + "openButton": "打开", + "okButton": "确定", + "noLabelsFound": "未找到标签", + "searchInputPlaceholder": "搜索或创建", + "assigneeSelectorInviteButton": "通过电子邮件邀请新成员", + "labelInputPlaceholder": "搜索或创建", + "pendingInvitation": "待处理邀请", + "contextMenu": { + "assignToMe": "分配给我", + "moveTo": "移动到", + "unarchive": "取消归档", + "archive": "归档", + "convertToSubTask": "转换为子任务", + "convertToTask": "转换为任务", + "delete": "删除", + "searchByNameInputPlaceholder": "按名称搜索" + }, + "setDueDate": "设置截止日期", + "setStartDate": "设置开始日期", + "clearDueDate": "清除截止日期", + "clearStartDate": "清除开始日期", + "dueDatePlaceholder": "截止日期", + "startDatePlaceholder": "开始日期", + + "emptyStates": { + "noTaskGroups": "未找到任务组", + "noTaskGroupsDescription": "创建任务或应用筛选器后,任务将显示在此处。", + "errorPrefix": "错误:", + "dragTaskFallback": "任务" + }, + + "customColumns": { + "addCustomColumn": "添加自定义列", + "customColumnHeader": "自定义列", + "customColumnSettings": "自定义列设置", + "noCustomValue": "无值", + "peopleField": "人员字段", + "noDate": "无日期", + "unsupportedField": "不支持的字段类型", + + "modal": { + "addFieldTitle": "添加字段", + "editFieldTitle": "编辑字段", + "fieldTitle": "字段标题", + "fieldTitleRequired": "字段标题为必填项", + "columnTitlePlaceholder": "列标题", + "type": "类型", + "deleteConfirmTitle": "确定要删除此自定义列吗?", + "deleteConfirmDescription": "此操作无法撤销。与此列关联的所有数据将被永久删除。", + "deleteButton": "删除", + "cancelButton": "取消", + "createButton": "创建", + "updateButton": "更新", + "createSuccessMessage": "自定义列创建成功", + "updateSuccessMessage": "自定义列更新成功", + "deleteSuccessMessage": "自定义列删除成功", + "deleteErrorMessage": "删除自定义列失败", + "createErrorMessage": "创建自定义列失败", + "updateErrorMessage": "更新自定义列失败" + }, + + "fieldTypes": { + "people": "人员", + "number": "数字", + "date": "日期", + "selection": "选择", + "checkbox": "复选框", + "labels": "标签", + "key": "键", + "formula": "公式" + } + }, + + "indicators": { + "tooltips": { + "subtasks": "{{count}} 个子任务", + "subtasks_plural": "{{count}} 个子任务", + "comments": "{{count}} 条评论", + "comments_plural": "{{count}} 条评论", + "attachments": "{{count}} 个附件", + "attachments_plural": "{{count}} 个附件", + "subscribers": "任务有订阅者", + "dependencies": "任务有依赖项", + "recurring": "重复任务" + } + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/task-management.json b/worklenz-frontend/public/locales/zh/task-management.json new file mode 100644 index 00000000..341ecc64 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/task-management.json @@ -0,0 +1,35 @@ +{ + "noTasksInGroup": "此组中没有任务", + "noTasksInGroupDescription": "添加任务开始使用", + "addFirstTask": "添加你的第一个任务", + "openTask": "打开", + "subtask": "子任务", + "subtasks": "子任务", + "comment": "评论", + "comments": "评论", + "attachment": "附件", + "attachments": "附件", + "enterSubtaskName": "输入子任务名称...", + "add": "添加", + "cancel": "取消", + "renameGroup": "重命名组", + "renameStatus": "重命名状态", + "renamePhase": "重命名阶段", + "changeCategory": "更改类别", + "clickToEditGroupName": "点击编辑组名称", + "enterGroupName": "输入组名称", + + "indicators": { + "tooltips": { + "subtasks": "{{count}} 个子任务", + "subtasks_plural": "{{count}} 个子任务", + "comments": "{{count}} 条评论", + "comments_plural": "{{count}} 条评论", + "attachments": "{{count}} 个附件", + "attachments_plural": "{{count}} 个附件", + "subscribers": "任务有订阅者", + "dependencies": "任务有依赖项", + "recurring": "重复任务" + } + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/task-template-drawer.json b/worklenz-frontend/public/locales/zh/task-template-drawer.json new file mode 100644 index 00000000..53e99119 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/task-template-drawer.json @@ -0,0 +1,11 @@ +{ + "createTaskTemplate": "创建任务模板", + "editTaskTemplate": "编辑任务模板", + "cancelText": "取消", + "saveText": "保存", + "templateNameText": "模板名称", + "selectedTasks": "已选任务", + "removeTask": "移除", + "cancelButton": "取消", + "saveButton": "保存" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/tasks/task-table-bulk-actions.json b/worklenz-frontend/public/locales/zh/tasks/task-table-bulk-actions.json new file mode 100644 index 00000000..2a4c89d6 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/tasks/task-table-bulk-actions.json @@ -0,0 +1,24 @@ +{ + "taskSelected": "任务已选择", + "tasksSelected": "任务已选择", + "changeStatus": "更改状态/优先级/阶段", + "changeLabel": "更改标签", + "assignToMe": "分配给我", + "changeAssignees": "更改受托人", + "archive": "归档", + "unarchive": "取消归档", + "delete": "删除", + "moreOptions": "更多选项", + "deselectAll": "取消全选", + "status": "状态", + "priority": "优先级", + "phase": "阶段", + "member": "成员", + "createTaskTemplate": "创建任务模板", + "apply": "应用", + "createLabel": "+ 创建标签", + "hitEnterToCreate": "按回车键创建", + "pendingInvitation": "待处理邀请", + "noMatchingLabels": "没有匹配的标签", + "noLabels": "没有标签" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/template-drawer.json b/worklenz-frontend/public/locales/zh/template-drawer.json new file mode 100644 index 00000000..64fd242f --- /dev/null +++ b/worklenz-frontend/public/locales/zh/template-drawer.json @@ -0,0 +1,19 @@ +{ + "title": "编辑任务模板", + "cancelText": "取消", + "saveText": "保存", + "templateNameText": "模板名称", + "selectedTasks": "已选任务", + "removeTask": "移除", + "description": "描述", + "phase": "阶段", + "statuses": "状态", + "priorities": "优先级", + "labels": "标签", + "tasks": "任务", + "noTemplateSelected": "未选择模板", + "noDescription": "无描述", + "worklenzTemplates": "Worklenz模板", + "yourTemplatesLibrary": "您的模板库", + "searchTemplates": "搜索模板" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/templateDrawer.json b/worklenz-frontend/public/locales/zh/templateDrawer.json new file mode 100644 index 00000000..8405f8ab --- /dev/null +++ b/worklenz-frontend/public/locales/zh/templateDrawer.json @@ -0,0 +1,23 @@ +{ + "bugTracking": "错误跟踪", + "construction": "建筑与施工", + "designCreative": "设计与创意", + "education": "教育", + "finance": "金融", + "hrRecruiting": "人力资源与招聘", + "informationTechnology": "信息技术", + "legal": "法律", + "manufacturing": "制造业", + "marketing": "市场营销", + "nonprofit": "非营利", + "personalUse": "个人使用", + "salesCRM": "销售与客户关系管理", + "serviceConsulting": "服务与咨询", + "softwareDevelopment": "软件开发", + "description": "描述", + "phase": "阶段", + "statuses": "状态", + "priorities": "优先级", + "labels": "标签", + "tasks": "任务" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/time-report.json b/worklenz-frontend/public/locales/zh/time-report.json new file mode 100644 index 00000000..c376954a --- /dev/null +++ b/worklenz-frontend/public/locales/zh/time-report.json @@ -0,0 +1,33 @@ +{ + "includeArchivedProjects": "包含已归档项目", + "export": "导出", + "timeSheet": "时间表", + "searchByName": "按名称搜索", + "selectAll": "全选", + "teams": "团队", + "searchByProject": "按项目名称搜索", + "projects": "项目", + "searchByCategory": "按类别名称搜索", + "categories": "类别", + "billable": "可计费", + "nonBillable": "不可计费", + "total": "总计", + "projectsTimeSheet": "项目时间表", + "loggedTime": "已记录时间(小时)", + "exportToExcel": "导出到Excel", + "logged": "已记录", + "for": "为", + "membersTimeSheet": "成员时间表", + "member": "成员", + "estimatedVsActual": "预计用时 vs 实际用时", + "workingDays": "工作日", + "manDays": "人天", + "days": "天", + "estimatedDays": "预计天数", + "actualDays": "实际天数", + "noCategories": "未找到类别", + "noCategory": "无类别", + "noProjects": "未找到项目", + "noTeams": "未找到团队", + "noData": "未找到数据" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/unauthorized.json b/worklenz-frontend/public/locales/zh/unauthorized.json new file mode 100644 index 00000000..985b1d08 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/unauthorized.json @@ -0,0 +1,5 @@ +{ + "title": "未授权!", + "subtitle": "您无权访问此页面", + "button": "返回首页" +} \ No newline at end of file diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 37a581b6..9fdd1605 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -2,7 +2,6 @@ import React, { Suspense, useEffect, memo, useMemo, useCallback } from 'react'; import { RouterProvider } from 'react-router-dom'; import i18next from 'i18next'; -import { ensureTranslationsLoaded } from './i18n'; // Components import ThemeWrapper from './features/theme/ThemeWrapper'; @@ -30,6 +29,7 @@ import { SuspenseFallback } from './components/suspense-fallback/suspense-fallba * 4. Lazy loading - All route components loaded on demand * 5. Suspense boundaries - Better loading states * 6. Optimized guard components with memoization + * 7. Deferred initialization - Non-critical operations moved to background */ const App: React.FC = memo(() => { const themeMode = useAppSelector(state => state.themeReducer.mode); @@ -38,8 +38,22 @@ const App: React.FC = memo(() => { // Memoize mixpanel initialization to prevent re-initialization const mixpanelToken = useMemo(() => import.meta.env.VITE_MIXPANEL_TOKEN as string, []); + // Defer mixpanel initialization to not block initial render useEffect(() => { - initMixpanel(mixpanelToken); + const initializeMixpanel = () => { + try { + initMixpanel(mixpanelToken); + } catch (error) { + logger.error('Failed to initialize Mixpanel:', error); + } + }; + + // Use requestIdleCallback to defer mixpanel initialization + if ('requestIdleCallback' in window) { + requestIdleCallback(initializeMixpanel, { timeout: 2000 }); + } else { + setTimeout(initializeMixpanel, 1000); + } }, [mixpanelToken]); // Memoize language change handler @@ -49,39 +63,54 @@ const App: React.FC = memo(() => { }); }, []); + // Apply theme immediately to prevent flash useEffect(() => { document.documentElement.setAttribute('data-theme', themeMode); }, [themeMode]); + // Handle language changes useEffect(() => { handleLanguageChange(language || Language.EN); }, [language, handleLanguageChange]); - // Initialize CSRF token and translations on app startup + // Initialize critical app functionality useEffect(() => { let isMounted = true; - const initializeApp = async () => { + const initializeCriticalApp = async () => { try { - // Initialize CSRF token + // Initialize CSRF token immediately as it's needed for API calls await initializeCsrfToken(); - - // Preload essential translations - await ensureTranslationsLoaded(); } catch (error) { if (isMounted) { - logger.error('Failed to initialize app:', error); + logger.error('Failed to initialize critical app functionality:', error); } } }; - initializeApp(); + // Initialize critical functionality immediately + initializeCriticalApp(); return () => { isMounted = false; }; }, []); + // Defer non-critical initialization + useEffect(() => { + const initializeNonCriticalApp = () => { + // Any non-critical initialization can go here + // For example: analytics, feature flags, etc. + }; + + // Defer non-critical initialization to not block initial render + if ('requestIdleCallback' in window) { + requestIdleCallback(initializeNonCriticalApp, { timeout: 3000 }); + } else { + setTimeout(initializeNonCriticalApp, 1500); + } + }, []); + return ( }> diff --git a/worklenz-frontend/src/components/AssigneeSelector.tsx b/worklenz-frontend/src/components/AssigneeSelector.tsx index 5f2bffcc..91866b7d 100644 --- a/worklenz-frontend/src/components/AssigneeSelector.tsx +++ b/worklenz-frontend/src/components/AssigneeSelector.tsx @@ -5,29 +5,25 @@ import { PlusOutlined, UserAddOutlined } from '@ant-design/icons'; import { RootState } from '@/app/store'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types'; -import { InlineMember } from '@/types/teamMembers/inlineMember.types'; import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import { useAuthService } from '@/hooks/useAuth'; -import { Avatar, Checkbox } from '@/components'; +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 { updateTaskAssignees } from '@/features/task-management/task-management.slice'; -import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types'; +import { updateEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice'; interface AssigneeSelectorProps { task: IProjectTask; groupId?: string | null; isDarkMode?: boolean; - kanbanMode?: boolean; // <-- Add this prop } -const AssigneeSelector: React.FC = ({ - task, - groupId = null, - isDarkMode = false, - kanbanMode = false, // <-- Default to false +const AssigneeSelector: React.FC = ({ + task, + groupId = null, + isDarkMode = false }) => { const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); @@ -35,12 +31,6 @@ const AssigneeSelector: React.FC = ({ const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }); const [optimisticAssignees, setOptimisticAssignees] = useState([]); // For optimistic updates const [pendingChanges, setPendingChanges] = useState>(new Set()); // Track pending member changes - - // Initialize optimistic assignees from task data on mount or when task changes - useEffect(() => { - const currentAssigneeIds = task?.assignees?.map(a => a.team_member_id) || []; - setOptimisticAssignees(currentAssigneeIds); - }, [task?.assignees]); const dropdownRef = useRef(null); const buttonRef = useRef(null); const searchInputRef = useRef(null); @@ -61,16 +51,9 @@ const AssigneeSelector: React.FC = ({ const updateDropdownPosition = useCallback(() => { if (buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect(); - const viewportHeight = window.innerHeight; - const dropdownHeight = 280; // More accurate height: header(40) + max-height(192) + footer(40) + padding - - // Check if dropdown would go below viewport - const spaceBelow = viewportHeight - rect.bottom; - const shouldShowAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight; - setDropdownPosition({ - top: shouldShowAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4, - left: rect.left, + top: rect.bottom + window.scrollY + 2, + left: rect.left + window.scrollX, }); } }, []); @@ -78,21 +61,27 @@ const AssigneeSelector: React.FC = ({ // Close dropdown when clicking outside and handle scroll useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) && - buttonRef.current && - !buttonRef.current.contains(event.target as Node) - ) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) && + buttonRef.current && !buttonRef.current.contains(event.target as Node)) { setIsOpen(false); } }; - const handleScroll = (event: Event) => { + const handleScroll = () => { if (isOpen) { - // Only close dropdown if scrolling happens outside the dropdown - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); + // 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; + + if (isVisible) { + updateDropdownPosition(); + } else { + // Hide dropdown if button is not visible + setIsOpen(false); + } } } }; @@ -107,7 +96,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); @@ -122,22 +111,19 @@ const AssigneeSelector: React.FC = ({ const handleDropdownToggle = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - + if (!isOpen) { updateDropdownPosition(); - - // Prepare team members data when opening - use optimistic assignees for current state - const currentAssigneeIds = optimisticAssignees.length > 0 - ? optimisticAssignees - : task?.assignees?.map(assignee => assignee.team_member_id) || []; - const membersData: (ITeamMembersViewModel & { selected?: boolean })[] = (members?.data || []).map(member => ({ + // Prepare team members data when opening + const assignees = task?.assignees?.map(assignee => assignee.team_member_id); + const membersData = (members?.data || []).map(member => ({ ...member, - selected: currentAssigneeIds.includes(member.id), + selected: assignees?.includes(member.id), })); const sortedMembers = sortTeamMembers(membersData); setTeamMembers({ data: sortedMembers }); - + setIsOpen(true); // Focus search input after opening setTimeout(() => { @@ -154,20 +140,16 @@ const AssigneeSelector: React.FC = ({ // Add to pending changes for visual feedback setPendingChanges(prev => new Set(prev).add(memberId)); - // Get the current list of assignees, prioritizing optimistic updates for immediate feedback - const currentAssigneeIds = optimisticAssignees.length > 0 - ? optimisticAssignees - : task?.assignees?.map(a => a.team_member_id) || []; - + // OPTIMISTIC UPDATE: Update local state immediately for instant UI feedback + const currentAssignees = task?.assignees?.map(a => a.team_member_id) || []; let newAssigneeIds: string[]; if (checked) { - // Adding assignee: ensure no duplicates - const uniqueIds = new Set([...currentAssigneeIds, memberId]); - newAssigneeIds = Array.from(uniqueIds); + // Adding assignee + newAssigneeIds = [...currentAssignees, memberId]; } else { // Removing assignee - newAssigneeIds = currentAssigneeIds.filter(id => id !== memberId); + newAssigneeIds = currentAssignees.filter(id => id !== memberId); } // Update optimistic state for immediate UI feedback in dropdown @@ -176,9 +158,11 @@ const AssigneeSelector: React.FC = ({ // Update local team members state for dropdown UI setTeamMembers(prev => ({ ...prev, - data: (prev.data || []).map(member => - member.id === memberId ? { ...member, selected: checked } : member - ), + data: (prev.data || []).map(member => + member.id === memberId + ? { ...member, selected: checked } + : member + ) })); const body = { @@ -192,35 +176,17 @@ const AssigneeSelector: React.FC = ({ // Emit socket event - the socket handler will update Redux with proper types socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body)); - socket?.once(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), (data: any) => { - // Instead of updating enhancedKanbanSlice, update the main taskManagementSlice - // Filter members to get the actual InlineMember objects for the new assignees - const updatedAssigneeNames: InlineMember[] = (members?.data || []) - .filter((member): member is ITeamMemberViewModel & { id: string; name: string } => { - return typeof member.id === 'string' && typeof member.name === 'string' && newAssigneeIds.includes(member.id); - }) - .map(member => ({ - name: member.name || '', - id: member.id || '', - team_member_id: member.id || '', - avatar_url: member.avatar_url || '', - color_code: member.color_code || '', - })); - - dispatch(updateTaskAssignees({ - taskId: task.id || '', - assigneeIds: newAssigneeIds, - assigneeNames: updatedAssigneeNames, - })); - if (kanbanMode) { + socket?.once( + SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), + (data: any) => { dispatch(updateEnhancedKanbanTaskAssignees(data)); } - }); + ); // Remove from pending changes after a short delay (optimistic) setTimeout(() => { setPendingChanges(prev => { - const newSet = new Set(Array.from(prev)); + const newSet = new Set(prev); newSet.delete(memberId); return newSet; }); @@ -229,8 +195,11 @@ const AssigneeSelector: React.FC = ({ const checkMemberSelected = (memberId: string) => { if (!memberId) return false; - // Always use optimistic assignees for dropdown display - return optimisticAssignees.includes(memberId); + // Use optimistic assignees if available, otherwise fall back to task assignees + const assignees = optimisticAssignees.length > 0 + ? optimisticAssignees + : task?.assignees?.map(assignee => assignee.team_member_id) || []; + return assignees.includes(memberId); }; const handleInviteProjectMemberDrawer = () => { @@ -246,159 +215,149 @@ 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' - : 'border-blue-500 bg-blue-50 text-blue-600' - : 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' + ${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' + : 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600' } `} > - {isOpen && - createPortal( -
e.stopPropagation()} - className={` + {isOpen && createPortal( +
e.stopPropagation()} + className={` fixed z-9999 w-72 rounded-md shadow-lg border - ${isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200'} + ${isDarkMode + ? 'bg-gray-800 border-gray-600' + : 'bg-white border-gray-200' + } `} - style={{ - top: dropdownPosition.top, - left: dropdownPosition.left, - }} - > - {/* Header */} -
- setSearchQuery(e.target.value)} - placeholder="Search members..." - className={` + style={{ + top: dropdownPosition.top, + left: dropdownPosition.left, + }} + > + {/* Header */} +
+ setSearchQuery(e.target.value)} + placeholder="Search members..." + className={` w-full px-2 py-1 text-xs rounded border - ${ - isDarkMode - ? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500' - : 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500' + ${isDarkMode + ? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500' + : 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500' } focus:outline-none focus:ring-1 focus:ring-blue-500 `} - /> -
+ /> +
- {/* Members List */} -
- {filteredMembers && filteredMembers.length > 0 ? ( - filteredMembers.map(member => ( -
+ {filteredMembers && filteredMembers.length > 0 ? ( + filteredMembers.map((member) => ( +
{ - if (!member.pending_invitation) { - const isSelected = checkMemberSelected(member.id || ''); - handleMemberToggle(member.id || '', !isSelected); - } - }} - style={{ - // Add visual feedback for immediate response - transition: 'all 0.15s ease-in-out', - }} - > -
- e.stopPropagation()}> - handleMemberToggle(member.id || '', checked)} - disabled={ - member.pending_invitation || pendingChanges.has(member.id || '') - } - isDarkMode={isDarkMode} - /> - - {pendingChanges.has(member.id || '') && ( -
-
-
+ onClick={() => { + if (!member.pending_invitation) { + const isSelected = checkMemberSelected(member.id || ''); + handleMemberToggle(member.id || '', !isSelected); + } + }} + style={{ + // Add visual feedback for immediate response + transition: 'all 0.15s ease-in-out', + }} + > +
+ e.stopPropagation()}> + handleMemberToggle(member.id || '', checked)} + disabled={member.pending_invitation || pendingChanges.has(member.id || '')} + isDarkMode={isDarkMode} + /> + + {pendingChanges.has(member.id || '') && ( +
+
+
+ )} +
+ + + +
+
+ {member.name} +
+
+ {member.email} + {member.pending_invitation && ( + (Pending) )}
- - - -
-
- {member.name} -
-
- {member.email} - {member.pending_invitation && ( - (Pending) - )} -
-
- )) - ) : ( -
-
No members found
- )} -
+ )) + ) : ( +
+
No members found
+
+ )} +
- {/* Footer */} -
- -
-
, - document.body - )} + onClick={handleInviteProjectMemberDrawer} + > + + Invite member + +
+
, + document.body + )} ); }; -export default AssigneeSelector; +export default AssigneeSelector; \ No newline at end of file diff --git a/worklenz-frontend/src/components/PinRouteToNavbarButton.tsx b/worklenz-frontend/src/components/PinRouteToNavbarButton.tsx index c7e99c1d..c2e415a9 100644 --- a/worklenz-frontend/src/components/PinRouteToNavbarButton.tsx +++ b/worklenz-frontend/src/components/PinRouteToNavbarButton.tsx @@ -5,8 +5,15 @@ import { PushpinFilled, PushpinOutlined } from '@ant-design/icons'; import { colors } from '../styles/colors'; import { navRoutes, NavRoutesType } from '../features/navbar/navRoutes'; +// Props type for the component +type PinRouteToNavbarButtonProps = { + name: string; + path: string; + adminOnly?: boolean; +}; + // this component pin the given path to navbar -const PinRouteToNavbarButton = ({ name, path }: NavRoutesType) => { +const PinRouteToNavbarButton = ({ name, path, adminOnly = false }: PinRouteToNavbarButtonProps) => { const navRoutesList: NavRoutesType[] = getJSONFromLocalStorage('navRoutes') || navRoutes; const [isPinned, setIsPinned] = useState( @@ -18,7 +25,7 @@ const PinRouteToNavbarButton = ({ name, path }: NavRoutesType) => { const handlePinToNavbar = (name: string, path: string) => { let newNavRoutesList; - const route: NavRoutesType = { name, path }; + const route: NavRoutesType = { name, path, adminOnly }; if (isPinned) { newNavRoutesList = navRoutesList.filter(item => item.name !== route.name); diff --git a/worklenz-frontend/src/components/charts/LazyChartComponents.tsx b/worklenz-frontend/src/components/charts/LazyChartComponents.tsx new file mode 100644 index 00000000..3a170a7b --- /dev/null +++ b/worklenz-frontend/src/components/charts/LazyChartComponents.tsx @@ -0,0 +1,84 @@ +import { lazy, Suspense } from 'react'; +import { Spin } from 'antd'; + +// Lazy load Chart.js components +const LazyBarChart = lazy(() => + import('react-chartjs-2').then(module => ({ default: module.Bar })) +); + +const LazyLineChart = lazy(() => + import('react-chartjs-2').then(module => ({ default: module.Line })) +); + +const LazyPieChart = lazy(() => + import('react-chartjs-2').then(module => ({ default: module.Pie })) +); + +const LazyDoughnutChart = lazy(() => + import('react-chartjs-2').then(module => ({ default: module.Doughnut })) +); + +// Lazy load Gantt components +const LazyGanttChart = lazy(() => + import('gantt-task-react').then(module => ({ default: module.Gantt })) +); + +// Chart loading fallback +const ChartLoadingFallback = () => ( +
+ +
+); + +// Wrapped components with Suspense +export const BarChart = (props: any) => ( + }> + + +); + +export const LineChart = (props: any) => ( + }> + + +); + +export const PieChart = (props: any) => ( + }> + + +); + +export const DoughnutChart = (props: any) => ( + }> + + +); + +export const GanttChart = (props: any) => ( + }> + + +); + +// Hook to preload chart libraries when needed +export const usePreloadCharts = () => { + const preloadCharts = () => { + // Preload Chart.js + import('react-chartjs-2'); + import('chart.js'); + + // Preload Gantt + import('gantt-task-react'); + }; + + return { preloadCharts }; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx index e8068e07..1b81906e 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx @@ -20,6 +20,7 @@ import { statusApiService } from '@/api/taskAttributes/status/status.api.service import alertService from '@/services/alerts/alertService'; import logger from '@/utils/errorLogger'; import Skeleton from 'antd/es/skeleton/Skeleton'; +import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status'; const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ projectId }) => { const dispatch = useDispatch(); @@ -120,15 +121,19 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project setDragType('task'); e.dataTransfer.effectAllowed = 'move'; }; - const handleTaskDragOver = (e: React.DragEvent, groupId: string, taskIdx: number) => { + const handleTaskDragOver = (e: React.DragEvent, groupId: string, taskIdx: number | null) => { if (dragType !== 'task') return; e.preventDefault(); if (draggedTaskId) { setHoveredGroupId(groupId); - setHoveredTaskIdx(taskIdx); } + if(taskIdx === null) { + setHoveredTaskIdx(0); + }else{ + setHoveredTaskIdx(taskIdx); + }; }; - const handleTaskDrop = (e: React.DragEvent, targetGroupId: string, targetTaskIdx: number) => { + const handleTaskDrop = async (e: React.DragEvent, targetGroupId: string, targetTaskIdx: number | null) => { if (dragType !== 'task') return; e.preventDefault(); if (!draggedTaskId || !draggedTaskGroupId || hoveredGroupId === null || hoveredTaskIdx === null) return; @@ -138,10 +143,23 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project const targetGroup = taskGroups.find(g => g.id === targetGroupId); if (!sourceGroup || !targetGroup) return; + const taskIdx = sourceGroup.tasks.findIndex(t => t.id === draggedTaskId); if (taskIdx === -1) return; const movedTask = sourceGroup.tasks[taskIdx]; + if (groupBy === 'status' && movedTask.id) { + if (sourceGroup.id !== targetGroup.id) { + const canContinue = await checkTaskDependencyStatus(movedTask.id, targetGroupId); + if (!canContinue) { + alertService.error( + 'Task is not completed', + 'Please complete the task dependencies before proceeding' + ); + return; + } + } + } let insertIdx = hoveredTaskIdx; // Handle same group reordering @@ -235,6 +253,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project task: movedTask, team_id: teamId, }); + } setDraggedTaskId(null); @@ -244,6 +263,11 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project setDragType(null); }; + const handleDragEnd = () => { + setHoveredGroupId(null); + setHoveredTaskIdx(null); + }; + useEffect(() => { if (!socket) return; @@ -313,6 +337,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project onTaskDragStart={handleTaskDragStart} onTaskDragOver={handleTaskDragOver} onTaskDrop={handleTaskDrop} + onDragEnd={handleDragEnd} hoveredTaskIdx={hoveredGroupId === group.id ? hoveredTaskIdx : null} hoveredGroupId={hoveredGroupId} /> diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx index 190d5d84..23b6613a 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx @@ -17,12 +17,12 @@ import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service import { ITaskPhase } from '@/types/tasks/taskPhase.types'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import { - deleteStatusToggleDrawer, - seletedStatusCategory, + deleteStatusToggleDrawer, + seletedStatusCategory, } from '@/features/projects/status/DeleteStatusSlice'; import { - fetchEnhancedKanbanGroups, - IGroupBy, + fetchEnhancedKanbanGroups, + IGroupBy, } from '@/features/enhanced-kanban/enhanced-kanban.slice'; @@ -32,8 +32,9 @@ interface KanbanGroupProps { onGroupDragOver: (e: React.DragEvent) => void; onGroupDrop: (e: React.DragEvent, groupId: string) => void; onTaskDragStart: (e: React.DragEvent, taskId: string, groupId: string) => void; - onTaskDragOver: (e: React.DragEvent, groupId: string, taskIdx: number) => void; - onTaskDrop: (e: React.DragEvent, groupId: string, taskIdx: number) => void; + onTaskDragOver: (e: React.DragEvent, groupId: string, taskIdx: number | null) => void; + onTaskDrop: (e: React.DragEvent, groupId: string, taskIdx: number | null) => void; + onDragEnd: (e: React.DragEvent) => void; hoveredTaskIdx: number | null; hoveredGroupId: string | null; } @@ -46,6 +47,7 @@ const KanbanGroup: React.FC = memo(({ onTaskDragStart, onTaskDragOver, onTaskDrop, + onDragEnd, hoveredTaskIdx, hoveredGroupId }) => { @@ -197,7 +199,10 @@ const KanbanGroup: React.FC = memo(({ setIsEditable(true); setShowDropdown(false); setTimeout(() => { - inputRef.current?.focus(); + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); // Select all text on focus + } }, 100); }; @@ -229,324 +234,370 @@ const KanbanGroup: React.FC = memo(({ }, [showDropdown]); return ( -
+
+ {/* Background layer - z-index 0 */}
onGroupDragStart(e, group.id)} - onDragOver={onGroupDragOver} - onDrop={e => onGroupDrop(e, group.id)} - > + onDragOver={e => { e.preventDefault(); onTaskDragOver(e, group.id, null); }} + onDrop={e => { e.preventDefault(); onTaskDrop(e, group.id, null); }} + /> + + {/* Content layer - z-index 1 */} +
setIsHover(true)} - onMouseLeave={() => setIsHover(false)} + className="enhanced-kanban-group-header" + style={{ + backgroundColor: headerBackgroundColor, + }} + draggable + onDragStart={e => onGroupDragStart(e, group.id)} + onDragOver={onGroupDragOver} + onDrop={e => onGroupDrop(e, group.id)} + onDragEnd={onDragEnd} >
{ - e.stopPropagation(); - if ((isProjectManager || isOwnerOrAdmin) && group.name !== t('unmapped')) - setIsEditable(true); - }} - onMouseDown={e => { - e.stopPropagation(); - }} + className="flex items-center justify-between w-full font-semibold rounded-md" + onMouseEnter={() => setIsHover(true)} + onMouseLeave={() => setIsHover(false)} > - {isLoading && ( -
- )} - {isEditable ? ( - { - e.stopPropagation(); - }} - onClick={e => { - e.stopPropagation(); - }} - /> - ) : ( -
{ - e.stopPropagation(); - e.preventDefault(); - }} - onMouseUp={e => { - e.stopPropagation(); - }} - onClick={e => { - e.stopPropagation(); - }} - > - {name} ({group.tasks.length}) -
- )} -
- -
- - - {(isOwnerOrAdmin || isProjectManager) && name !== t('unmapped') && ( -
- - - {showDropdown && ( -
-
- - - {groupBy === IGroupBy.STATUS && statusCategories && ( -
-
- {t('changeCategory')} -
- {statusCategories.map(status => ( - - ))} -
- )} - - {groupBy !== IGroupBy.PRIORITY && ( -
- -
- )} -
-
- )} -
- )} -
-
-
- - {/* Simple Delete Confirmation */} - {showDeleteConfirm && ( -
-
-
-
-
- - - -
-
-

- {t('deleteConfirmationTitle')} -

-
-
-
- - -
+ {name} ({group.tasks.length}) +
+ )} +
+ +
+ + + {(isOwnerOrAdmin || isProjectManager) && name !== t('unmapped') && ( +
+ + + {showDropdown && ( +
+
+ + + {groupBy === IGroupBy.STATUS && statusCategories && ( +
+
+ {t('changeCategory')} +
+ {statusCategories.map(status => ( + + ))} +
+ )} + + {groupBy !== IGroupBy.PRIORITY && ( +
+ +
+ )} +
+
+ )} +
+ )}
- )} -
- {/* Create card at top */} - {showNewCardTop && (isOwnerOrAdmin || isProjectManager) && ( - - )} - {/* If group is empty, render a drop zone */} - {group.tasks.length === 0 && !showNewCardTop && !showNewCardBottom &&( -
{ e.preventDefault(); onTaskDragOver(e, group.id, 0); }} - onDrop={e => { e.preventDefault(); onTaskDrop(e, group.id, 0); }} - > - {/* Drop indicator at the end of the group */} - {hoveredGroupId === group.id && hoveredTaskIdx === group.tasks.length && ( -
-
+ {/* Simple Delete Confirmation */} + {showDeleteConfirm && ( +
+
+
+
+
+ + + +
+
+

+ {t('deleteConfirmationTitle')} +

+
+
+
+ + +
- )} - {(isOwnerOrAdmin || isProjectManager) && !showNewCardTop && !showNewCardBottom && ( - - )} -
- ) - } - - - {/* Drop indicator at the top of the group */} - {hoveredGroupId === group.id && hoveredTaskIdx === 0 && ( -
-
+
)} +
+ {/* Create card at top */} + {showNewCardTop && ( + + )} - {group.tasks.map((task, idx) => ( - - ))} + {/* If group is empty, render a drop zone */} + {group.tasks.length === 0 && !showNewCardTop && !showNewCardBottom && ( +
{ e.preventDefault(); onTaskDragOver(e, group.id, 0); }} + onDrop={e => { e.preventDefault(); onTaskDrop(e, group.id, 0); }} + > + {/* Drop indicator at the end of the group */} + {hoveredGroupId === group.id && hoveredTaskIdx === group.tasks.length && ( +
+
+
+ )} + {(isOwnerOrAdmin || isProjectManager) && !showNewCardTop && !showNewCardBottom && ( + + )} +
+ ) + } - {/* Create card at bottom */} - {showNewCardBottom && (isOwnerOrAdmin || isProjectManager) && ( - - )} - {/* Footer Add Task Button */} - {(isOwnerOrAdmin || isProjectManager) && !showNewCardTop && !showNewCardBottom && group.tasks.length > 0 && ( - - )} - - {/* Drop indicator at the end of the group */} - {hoveredGroupId === group.id && hoveredTaskIdx === group.tasks.length && ( -
-
+
- )} + )} + + {/* Create card at bottom */} + {showNewCardBottom && ( + + )} + + {/* Footer Add Task Button */} + {!showNewCardTop && !showNewCardBottom && group.tasks.length > 0 && ( + + )} + + {/* Drop indicator at the end of the group */} + {hoveredGroupId === group.id && hoveredTaskIdx === group.tasks.length && ( +
+
+
+ )} +
- -
); }); diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx index 3a538463..fea952f0 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx @@ -28,8 +28,8 @@ interface TaskCardProps { onTaskDragOver: (e: React.DragEvent, groupId: string, taskIdx: number) => void; onTaskDrop: (e: React.DragEvent, groupId: string, taskIdx: number) => void; groupId: string; - isDropIndicator: boolean; idx: number; + onDragEnd: (e: React.DragEvent) => void; // <-- add this } function getDaysInMonth(year: number, month: number) { @@ -46,8 +46,8 @@ const TaskCard: React.FC = memo(({ onTaskDragOver, onTaskDrop, groupId, - isDropIndicator, - idx + idx, + onDragEnd // <-- add this }) => { const { socket } = useSocket(); const themeMode = useSelector((state: RootState) => state.themeReducer.mode); @@ -198,29 +198,24 @@ const TaskCard: React.FC = memo(({ while (week.length < 7) week.push(null); weeks.push(week); } + const [isDown, setIsDown] = useState(false); return ( <> - {isDropIndicator && ( -
onTaskDragStart(e, task.id!, groupId)} - onDragOver={e => onTaskDragOver(e, groupId, idx)} - onDrop={e => onTaskDrop(e, groupId, idx)} - /> - )}
onTaskDragStart(e, task.id!, groupId)} - onDragOver={e => onTaskDragOver(e, groupId, idx)} + onDragOver={e => { + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const offsetY = e.clientY - rect.top; + const isDown = offsetY > rect.height / 2; + setIsDown(isDown); + onTaskDragOver(e, groupId, isDown ? idx + 1 : idx); + }} onDrop={e => onTaskDrop(e, groupId, idx)} - + onDragEnd={onDragEnd} // <-- add this onClick={e => handleCardClick(e, task.id!)} >
@@ -429,7 +424,16 @@ const TaskCard: React.FC = memo(({
- {task.show_sub_tasks && ( +
{/* Loading state */} {task.sub_tasks_loading && ( @@ -469,7 +473,7 @@ const TaskCard: React.FC = memo(({
{t('noSubtasks', 'No subtasks')}
)}
- )} +
); diff --git a/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx b/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx index 1e919118..bb07669d 100644 --- a/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx +++ b/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx @@ -124,10 +124,25 @@ const ProjectGroupList: React.FC = ({ // Action handlers const handleSettingsClick = (e: React.MouseEvent, projectId: string) => { e.stopPropagation(); + console.log('Opening project drawer from project group for project:', projectId); trackMixpanelEvent(evt_projects_settings_click); + + // Set project ID first dispatch(setProjectId(projectId)); - dispatch(fetchProjectData(projectId)); - dispatch(toggleProjectDrawer()); + + // Then fetch project data + dispatch(fetchProjectData(projectId)) + .unwrap() + .then((projectData) => { + console.log('Project data fetched successfully from project group:', projectData); + // Open drawer after data is fetched + dispatch(toggleProjectDrawer()); + }) + .catch((error) => { + console.error('Failed to fetch project data from project group:', error); + // Still open drawer even if fetch fails, so user can see error state + dispatch(toggleProjectDrawer()); + }); }; const handleArchiveClick = async ( diff --git a/worklenz-frontend/src/components/project-list/project-list-table/project-list-actions/project-list-actions.tsx b/worklenz-frontend/src/components/project-list/project-list-table/project-list-actions/project-list-actions.tsx index 57c14e36..c447ddeb 100644 --- a/worklenz-frontend/src/components/project-list/project-list-table/project-list-actions/project-list-actions.tsx +++ b/worklenz-frontend/src/components/project-list/project-list-table/project-list-actions/project-list-actions.tsx @@ -46,10 +46,25 @@ export const ActionButtons: React.FC = ({ const handleSettingsClick = () => { if (record.id) { + console.log('Opening project drawer for project:', record.id); trackMixpanelEvent(evt_projects_settings_click); + + // Set project ID first dispatch(setProjectId(record.id)); - dispatch(fetchProjectData(record.id)); - dispatch(toggleProjectDrawer()); + + // Then fetch project data + dispatch(fetchProjectData(record.id)) + .unwrap() + .then((projectData) => { + console.log('Project data fetched successfully:', projectData); + // Open drawer after data is fetched + dispatch(toggleProjectDrawer()); + }) + .catch((error) => { + console.error('Failed to fetch project data:', error); + // Still open drawer even if fetch fails, so user can see error state + dispatch(toggleProjectDrawer()); + }); } }; diff --git a/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx b/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx index 7bae3717..f6519cb9 100644 --- a/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx +++ b/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx @@ -72,6 +72,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { null ); const [isFormValid, setIsFormValid] = useState(true); + const [drawerVisible, setDrawerVisible] = useState(false); // Selectors const { clients, loading: loadingClients } = useAppSelector(state => state.clientReducer); @@ -131,6 +132,60 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { loadInitialData(); }, [dispatch]); + // New effect to handle form population when project data becomes available + useEffect(() => { + if (drawerVisible && projectId && project && !projectLoading) { + console.log('Populating form with project data:', project); + setEditMode(true); + + try { + form.setFieldsValue({ + ...project, + start_date: project.start_date ? dayjs(project.start_date) : null, + end_date: project.end_date ? dayjs(project.end_date) : null, + working_days: project.working_days || 0, + use_manual_progress: project.use_manual_progress || false, + use_weighted_progress: project.use_weighted_progress || false, + use_time_progress: project.use_time_progress || false, + }); + + setSelectedProjectManager(project.project_manager || null); + setLoading(false); + console.log('Form populated successfully with project data'); + } catch (error) { + console.error('Error setting form values:', error); + logger.error('Error setting form values in project drawer', error); + setLoading(false); + } + } else if (drawerVisible && !projectId) { + // Creating new project + console.log('Setting up drawer for new project creation'); + setEditMode(false); + setLoading(false); + } else if (drawerVisible && projectId && !project && !projectLoading) { + // Project data failed to load or is empty + console.warn('Project drawer is visible but no project data available'); + setLoading(false); + } else if (drawerVisible && projectId) { + console.log('Drawer visible, waiting for project data to load...'); + } + }, [drawerVisible, projectId, project, projectLoading, form]); + + // Additional effect to handle loading state when project data is being fetched + useEffect(() => { + if (drawerVisible && projectId && projectLoading) { + console.log('Project data is loading, maintaining loading state'); + setLoading(true); + } + }, [drawerVisible, projectId, projectLoading]); + + // Define resetForm function early to avoid declaration order issues + const resetForm = useCallback(() => { + setEditMode(false); + form.resetFields(); + setSelectedProjectManager(null); + }, [form]); + useEffect(() => { const startDate = form.getFieldValue('start_date'); const endDate = form.getFieldValue('end_date'); @@ -226,47 +281,33 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { return workingDays; }; + // Improved handleVisibilityChange to track drawer state without doing form operations const handleVisibilityChange = useCallback( (visible: boolean) => { - if (visible && projectId) { - setEditMode(true); - if (project) { - form.setFieldsValue({ - ...project, - start_date: project.start_date ? dayjs(project.start_date) : null, - end_date: project.end_date ? dayjs(project.end_date) : null, - working_days: - form.getFieldValue('start_date') && form.getFieldValue('end_date') - ? calculateWorkingDays( - form.getFieldValue('start_date'), - form.getFieldValue('end_date') - ) - : project.working_days || 0, - use_manual_progress: project.use_manual_progress || false, - use_weighted_progress: project.use_weighted_progress || false, - use_time_progress: project.use_time_progress || false, - }); - setSelectedProjectManager(project.project_manager || null); - setLoading(false); - } - } else { + console.log('Drawer visibility changed:', visible, 'Project ID:', projectId); + setDrawerVisible(visible); + + if (!visible) { resetForm(); + } else if (visible && !projectId) { + // Creating new project - reset form immediately + console.log('Opening drawer for new project'); + setEditMode(false); + setLoading(false); + } else if (visible && projectId) { + // Editing existing project - loading state will be handled by useEffect + console.log('Opening drawer for existing project:', projectId); + setLoading(true); } }, - [projectId, project] + [projectId, resetForm] ); - const resetForm = useCallback(() => { - setEditMode(false); - form.resetFields(); - setSelectedProjectManager(null); - }, [form]); - const handleDrawerClose = useCallback(() => { setLoading(true); + setDrawerVisible(false); resetForm(); dispatch(setProjectData({} as IProjectViewModel)); - // dispatch(setProjectId(null)); dispatch(setDrawerProjectId(null)); dispatch(toggleProjectDrawer()); onClose(); @@ -405,7 +446,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { {!isEditable && ( )} - +
{ + const { t } = useTranslation('settings/teams'); const dispatch = useAppDispatch(); const [form] = Form.useForm(); const [updating, setUpdating] = useState(false); @@ -33,7 +35,7 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro } setUpdating(false); } catch (error) { - message.error('Team name change failed!'); + message.error(t('updateFailed')); } finally { setUpdating(false); } @@ -49,13 +51,13 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro width: '100%', }} > - Edit Team Name + {t('editTeamName')} } open={isModalOpen} onOk={form.submit} - okText="Update Name" + okText={t('updateName')} onCancel={() => { onCancel(); setUpdating(false); @@ -67,15 +69,15 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro - + diff --git a/worklenz-frontend/src/components/settings/update-member-drawer.tsx b/worklenz-frontend/src/components/settings/update-member-drawer.tsx index a7da0075..1af06fcc 100644 --- a/worklenz-frontend/src/components/settings/update-member-drawer.tsx +++ b/worklenz-frontend/src/components/settings/update-member-drawer.tsx @@ -65,7 +65,7 @@ const UpdateMemberDrawer = ({ selectedMemberId, onRoleUpdate }: UpdateMemberDraw setJobTitles(res.body.data || []); } } catch (error) { - console.error('Error fetching job titles:', error); + logger.error('Error fetching job titles:', error); message.error(t('jobTitlesFetchError')); } finally { setLoading(false); diff --git a/worklenz-frontend/src/components/task-drawer/shared/activity-log/task-drawer-activity-log.tsx b/worklenz-frontend/src/components/task-drawer/shared/activity-log/task-drawer-activity-log.tsx index d0dcd76e..7b23a596 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/activity-log/task-drawer-activity-log.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/activity-log/task-drawer-activity-log.tsx @@ -20,7 +20,7 @@ const TaskDrawerActivityLog = () => { const [loading, setLoading] = useState(false); const { selectedTaskId, taskFormViewModel } = useAppSelector(state => state.taskDrawerReducer); const { mode: themeMode } = useAppSelector(state => state.themeReducer); - const { t } = useTranslation(); + const { t } = useTranslation('task-drawer/task-drawer'); useEffect(() => { fetchActivityLogs(); @@ -73,7 +73,7 @@ const TaskDrawerActivityLog = () => {   - {activity.log_type === 'create' ? 'ADD' : 'REMOVE'} + {activity.log_type === 'create' ? t('taskActivityLogTab.add') : t('taskActivityLogTab.remove')} ); @@ -87,7 +87,7 @@ const TaskDrawerActivityLog = () => { : activity.previous_status?.color_code } > - {truncateText(activity.previous_status?.name) || 'None'} + {truncateText(activity.previous_status?.name) || t('taskActivityLogTab.none')}   @@ -98,7 +98,7 @@ const TaskDrawerActivityLog = () => { : activity.next_status?.color_code } > - {truncateText(activity.next_status?.name) || 'None'} + {truncateText(activity.next_status?.name) || t('taskActivityLogTab.none')} ); @@ -113,7 +113,7 @@ const TaskDrawerActivityLog = () => { : activity.previous_priority?.color_code } > - {truncateText(activity.previous_priority?.name) || 'None'} + {truncateText(activity.previous_priority?.name) || t('taskActivityLogTab.none')}   @@ -124,7 +124,7 @@ const TaskDrawerActivityLog = () => { : activity.next_priority?.color_code } > - {truncateText(activity.next_priority?.name) || 'None'} + {truncateText(activity.next_priority?.name) || t('taskActivityLogTab.none')} ); @@ -133,12 +133,12 @@ const TaskDrawerActivityLog = () => { return ( - {truncateText(activity.previous_phase?.name) || 'None'} + {truncateText(activity.previous_phase?.name) || t('taskActivityLogTab.none')}   - {truncateText(activity.next_phase?.name) || 'None'} + {truncateText(activity.next_phase?.name) || t('taskActivityLogTab.none')} ); @@ -156,20 +156,20 @@ const TaskDrawerActivityLog = () => { case IActivityLogAttributeTypes.WEIGHT: return ( - Weight: {activity.previous || '100'} + {t('taskActivityLogTab.weight')}: {activity.previous || '100'}   - Weight: {activity.current || '100'} + {t('taskActivityLogTab.weight')}: {activity.current || '100'} ); default: return ( - {truncateText(activity.previous) || 'None'} + {truncateText(activity.previous) || t('taskActivityLogTab.none')}   - {truncateText(activity.current) || 'None'} + {truncateText(activity.current) || t('taskActivityLogTab.none')} ); } @@ -222,7 +222,7 @@ const TaskDrawerActivityLog = () => { {activityLogs.name} - created the task. + {t('taskActivityLogTab.createdTask')} + import('@tinymce/tinymce-react').then(module => ({ default: module.Editor })) +); + interface DescriptionEditorProps { description: string | null; taskId: string; @@ -17,23 +21,51 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi const [isEditorOpen, setIsEditorOpen] = useState(false); const [content, setContent] = useState(description || ''); const [isEditorLoading, setIsEditorLoading] = useState(false); - const [wordCount, setWordCount] = useState(0); // State for word count + const [wordCount, setWordCount] = useState(0); + const [isTinyMCELoaded, setIsTinyMCELoaded] = useState(false); const editorRef = useRef(null); const wrapperRef = useRef(null); const themeMode = useAppSelector(state => state.themeReducer.mode); - // Preload TinyMCE script - useEffect(() => { - const preloadTinyMCE = () => { - const link = document.createElement('link'); - link.rel = 'preload'; - link.href = '/tinymce/tinymce.min.js'; - link.as = 'script'; - document.head.appendChild(link); - }; + // CSS styles for description content links + const descriptionStyles = ` + .description-content a { + color: ${themeMode === 'dark' ? '#4dabf7' : '#1890ff'} !important; + text-decoration: underline !important; + cursor: pointer !important; + } + .description-content a:hover { + color: ${themeMode === 'dark' ? '#74c0fc' : '#40a9ff'} !important; + } + `; - preloadTinyMCE(); - }, []); + // Load TinyMCE script only when editor is opened + const loadTinyMCE = async () => { + if (isTinyMCELoaded) return; + + setIsEditorLoading(true); + try { + // Load TinyMCE script dynamically + await new Promise((resolve, reject) => { + if ((window as any).tinymce) { + resolve(); + return; + } + + const script = document.createElement('script'); + script.src = '/tinymce/tinymce.min.js'; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load TinyMCE')); + document.head.appendChild(script); + }); + + setIsTinyMCELoaded(true); + } catch (error) { + console.error('Failed to load TinyMCE:', error); + setIsEditorLoading(false); + } + }; const handleDescriptionChange = () => { if (!taskId) return; @@ -55,7 +87,7 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi const isClickedInsideWrapper = wrapper && wrapper.contains(target); const isClickedInsideEditor = document.querySelector('.tox-tinymce')?.contains(target); const isClickedInsideToolbarPopup = document - .querySelector('.tox-menu, .tox-pop, .tox-collection') + .querySelector('.tox-menu, .tox-pop, .tox-collection, .tox-dialog, .tox-dialog-wrap, .tox-silver-sink') ?.contains(target); if ( @@ -80,7 +112,6 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi const handleEditorChange = (content: string) => { const sanitizedContent = DOMPurify.sanitize(content); setContent(sanitizedContent); - // Update word count when content changes if (editorRef.current) { const count = editorRef.current.plugins.wordcount.getCount(); setWordCount(count); @@ -90,15 +121,36 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi const handleInit = (evt: any, editor: any) => { editorRef.current = editor; editor.on('focus', () => setIsEditorOpen(true)); - // Set initial word count on init const initialCount = editor.plugins.wordcount.getCount(); setWordCount(initialCount); setIsEditorLoading(false); }; - const handleOpenEditor = () => { + const handleOpenEditor = async () => { setIsEditorOpen(true); - setIsEditorLoading(true); + await loadTinyMCE(); + }; + + const handleContentClick = (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + + // Check if clicked element is a link + if (target.tagName === 'A' || target.closest('a')) { + event.preventDefault(); // Prevent default link behavior + event.stopPropagation(); // Prevent opening the editor + const link = target.tagName === 'A' ? target : target.closest('a'); + if (link) { + const href = (link as HTMLAnchorElement).href; + if (href) { + // Open link in new tab/window for security + window.open(href, '_blank', 'noopener,noreferrer'); + } + } + return; + } + + // If not a link, open the editor + handleOpenEditor(); }; const darkModeStyles = @@ -116,6 +168,8 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi return (
+ {/* Inject CSS styles for links */} + {isEditorOpen ? (
Loading editor...
)} - { - editor.dom.setStyle( - editor.getBody(), - 'backgroundColor', - themeMode === 'dark' ? '#1e1e1e' : '#ffffff' - ); - }, - }} - onEditorChange={handleEditorChange} - /> + {isTinyMCELoaded && ( + Loading editor...
}> + { + editor.dom.setStyle( + editor.getBody(), + 'backgroundColor', + themeMode === 'dark' ? '#1e1e1e' : '#ffffff' + ); + }, + }} + onEditorChange={handleEditorChange} + /> + + )}
) : (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} style={{ - minHeight: '32px', - padding: '4px 11px', - border: `1px solid ${isHovered ? (themeMode === 'dark' ? '#177ddc' : '#40a9ff') : 'transparent'}`, + minHeight: '40px', + padding: '8px 12px', + border: `1px solid ${themeMode === 'dark' ? '#424242' : '#d9d9d9'}`, borderRadius: '6px', cursor: 'pointer', + backgroundColor: isHovered + ? themeMode === 'dark' + ? '#2a2a2a' + : '#fafafa' + : themeMode === 'dark' + ? '#1e1e1e' + : '#ffffff', color: themeMode === 'dark' ? '#ffffff' : '#000000', - transition: 'border-color 0.3s ease', + transition: 'all 0.2s ease', }} > {content ? (
) : ( - - Add a more detailed description... - +
+ Click to add description... +
)}
)} diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx index e63c2a3c..3ab435d8 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx @@ -1,18 +1,14 @@ import { Button, Flex, Form, Mentions, Space, Tooltip, Typography, message } from 'antd'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { PaperClipOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { updateTaskCounts } from '@/features/task-management/task-management.slice'; import { colors } from '@/styles/colors'; import { themeWiseColor } from '@/utils/themeWiseColor'; -import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale'; -import { calculateTimeDifference } from '@/utils/calculate-time-difference'; import { IMentionMemberSelectOption, - IMentionMemberViewModel, } from '@/types/project/projectComments.types'; -import { projectCommentsApiService } from '@/api/projects/comments/project-comments.api.service'; import { ITaskCommentsCreateRequest } from '@/types/tasks/task-comments.types'; import { ITaskAttachment } from '@/types/tasks/task-attachment-view-model'; import logger from '@/utils/errorLogger'; @@ -40,6 +36,7 @@ const formatFileSize = (bytes: number): string => { }; const InfoTabFooter = () => { + const { t } = useTranslation('task-drawer/task-drawer'); const MAXIMUM_FILE_COUNT = 5; const [characterLength, setCharacterLength] = useState(0); @@ -76,6 +73,8 @@ const InfoTabFooter = () => { setIsCommentBoxExpand(false); setSelectedFiles([]); setAttachmentComment(false); + setCommentValue(''); + setSelectedMembers([]); }; // Check if comment is valid (either has text or files) @@ -134,7 +133,7 @@ const InfoTabFooter = () => { if (!selectedTaskId || !projectId) return; if (!isCommentValid()) { - message.error('Please add a comment or attach files'); + message.error(t('taskInfoTab.comments.addCommentError')); return; } @@ -157,7 +156,8 @@ const InfoTabFooter = () => { setAttachmentComment(false); setIsCommentBoxExpand(false); setCommentValue(''); - + setSelectedMembers([]); + // Dispatch event to notify that a comment was created // This will trigger the task comments component to refresh and update Redux document.dispatchEvent(new CustomEvent('task-comment-create', { @@ -185,7 +185,7 @@ const InfoTabFooter = () => { const files = Array.from(event.target.files); if (selectedFiles.length + files.length > MAXIMUM_FILE_COUNT) { - message.error(`You can only upload a maximum of ${MAXIMUM_FILE_COUNT} files`); + message.error(t('taskInfoTab.comments.maxFilesError', { count: MAXIMUM_FILE_COUNT })); return; } @@ -216,7 +216,7 @@ const InfoTabFooter = () => { } } catch (error) { console.error('Failed to process files:', error); - message.error('Failed to process files'); + message.error(t('taskInfoTab.comments.processFilesError')); } finally { setUploading(false); @@ -276,7 +276,7 @@ const InfoTabFooter = () => { }} > { {selectedFiles.length > 0 && ( - Selected Files (Up to 25MB, Maximum of {MAXIMUM_FILE_COUNT}) +{t('taskInfoTab.comments.selectedFiles', { count: MAXIMUM_FILE_COUNT })} { icon={} disabled={selectedFiles.length >= MAXIMUM_FILE_COUNT || uploading} > - Add more files + {t('taskInfoTab.comments.addMoreFiles')} @@ -375,7 +375,7 @@ const InfoTabFooter = () => {
{ = MAXIMUM_FILE_COUNT - ? `Maximum ${MAXIMUM_FILE_COUNT} files allowed` - : 'Attach files' + ? t('taskInfoTab.comments.maxFilesError', { count: MAXIMUM_FILE_COUNT }) + : t('taskInfoTab.comments.attachFiles') } > + @@ -454,31 +454,29 @@ const InfoTabFooter = () => { - Created{' '} - {taskFormViewModel?.task?.created_at - ? calculateTimeDifference(taskFormViewModel.task.created_at) - : 'N/A'}{' '} - by {taskFormViewModel?.task?.reporter} +{t('taskInfoTab.comments.createdBy', { + time: taskFormViewModel?.task?.created_from_now || 'N/A', + user: taskFormViewModel?.task?.reporter || '' + })} - Updated{' '} - {taskFormViewModel?.task?.updated_at - ? calculateTimeDifference(taskFormViewModel.task.updated_at) - : 'N/A'} +{t('taskInfoTab.comments.updatedTime', { + time: taskFormViewModel?.task?.updated_from_now || 'N/A' + })} diff --git a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-form.tsx index 4acd8e88..91463e6b 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-form.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-form.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Button, DatePicker, Form, Input, TimePicker, Flex } from 'antd'; import { ClockCircleOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; import dayjs from 'dayjs'; import { useAppSelector } from '@/hooks/useAppSelector'; @@ -25,6 +26,7 @@ const TimeLogForm = ({ initialValues, mode = 'create', }: TimeLogFormProps) => { + const { t } = useTranslation('task-drawer/task-drawer'); const currentSession = useAuthService().getCurrentSession(); const { socket, connected } = useSocket(); const [form] = Form.useForm(); @@ -140,7 +142,7 @@ const TimeLogForm = ({ form.setFields([ { name: 'endTime', - errors: ['End time must be after start time'], + errors: [t('taskTimeLogTab.timeLogForm.endTimeAfterStartError')], }, ]); return; @@ -219,44 +221,44 @@ const TimeLogForm = ({ current && current.toDate() > new Date()} /> - - + + - + diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer.tsx index bbec5479..335ad133 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer.tsx @@ -154,7 +154,7 @@ const TaskDrawer = () => { onClick={handleAddTimeLog} style={{ width: '100%' }} > - Add new time log + {t('taskTimeLogTab.addTimeLog')} ); @@ -222,7 +222,7 @@ const TaskDrawer = () => { diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx index 37af547a..0d6f5df4 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx @@ -471,7 +471,7 @@ const TaskListV2: React.FC = () => { // Render column headers const renderColumnHeaders = useCallback(() => (
-
+
{visibleColumns.map((column, index) => { const columnStyle: ColumnStyle = { width: column.width, @@ -490,7 +490,21 @@ const TaskListV2: React.FC = () => {
@@ -508,7 +522,7 @@ const TaskListV2: React.FC = () => { ); })} {/* Add Custom Column Button - positioned at the end and scrolls with content */} -
+
@@ -519,22 +533,22 @@ const TaskListV2: React.FC = () => { // Loading and error states if (loading || loadingColumns) return ; - if (error) return
Error: {error}
; + if (error) return
{t('emptyStates.errorPrefix')} {error}
; // Show message when no data if (groups.length === 0 && !loading) { return (
-
+
- No task groups found + {t('emptyStates.noTaskGroups')}
- Tasks will appear here when they are created or when filters are applied. + {t('emptyStates.noTaskGroupsDescription')}
@@ -552,65 +566,66 @@ const TaskListV2: React.FC = () => { >
{/* Task Filters */} -
+
- {/* Spacing between filters and table */} -
- - {/* Table Container */} -
- {/* Task List Content with Sticky Header */} + {/* Table Container */}
- {/* Sticky Column Headers */} -
- {renderColumnHeaders()} -
- !('isAddTaskRow' in item) && !item.parent_task_id) - .map(item => item.id) - .filter((id): id is string => id !== undefined)} - strategy={verticalListSortingStrategy} + {/* Task List Content with Sticky Header */} +
-
- {/* Render groups manually for debugging */} - {virtuosoGroups.map((group, groupIndex) => ( -
- {/* Group Header */} - {renderGroup(groupIndex)} - - {/* Group Tasks */} - {!collapsedGroups.has(group.id) && group.tasks.map((task, taskIndex) => { - const globalTaskIndex = virtuosoGroups - .slice(0, groupIndex) - .reduce((sum, g) => sum + g.count, 0) + taskIndex; - - return ( -
- {renderTask(globalTaskIndex)} -
- ); - })} + {/* Sticky Column Headers */} +
+ {renderColumnHeaders()} +
+ !('isAddTaskRow' in item) && !item.parent_task_id) + .map(item => item.id) + .filter((id): id is string => id !== undefined)} + strategy={verticalListSortingStrategy} + > +
+ {/* Render groups manually for debugging */} + {virtuosoGroups.map((group, groupIndex) => ( +
+ {/* Group Header */} + {renderGroup(groupIndex)} + + {/* Group Tasks */} + {!collapsedGroups.has(group.id) && group.tasks.map((task, taskIndex) => { + const globalTaskIndex = virtuosoGroups + .slice(0, groupIndex) + .reduce((sum, g) => sum + g.count, 0) + taskIndex; + + return ( +
+ {renderTask(globalTaskIndex)} +
+ ); + })} +
+ ))}
- ))} +
- -
-
+
{/* Drag Overlay */} @@ -623,7 +638,7 @@ const TaskListV2: React.FC = () => {
{allTasks.find(task => task.id === activeId)?.name || allTasks.find(task => task.id === activeId)?.title || - 'Task'} + t('emptyStates.dragTaskFallback')}
{allTasks.find(task => task.id === activeId)?.task_key} diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index e85ab195..b3130cf8 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -301,7 +301,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn ); @@ -1213,7 +1240,7 @@ const ImprovedTaskFilters: React.FC = ({ position, cla return (
{/* Left Section - Main Filters */} diff --git a/worklenz-frontend/src/features/i18n/language-selector.tsx b/worklenz-frontend/src/features/i18n/language-selector.tsx index 7af61f85..fd61c8c0 100644 --- a/worklenz-frontend/src/features/i18n/language-selector.tsx +++ b/worklenz-frontend/src/features/i18n/language-selector.tsx @@ -17,6 +17,7 @@ const LanguageSelector = () => { { key: 'pt', label: 'Português' }, { key: 'alb', label: 'Shqip' }, { key: 'de', label: 'Deutsch' }, + { key: 'zh_cn', label: '简体中文' }, ]; const languageLabels = { @@ -25,6 +26,7 @@ const LanguageSelector = () => { pt: 'Pt', alb: 'Sq', de: 'de', + zh_cn: 'zh_cn', }; return ( diff --git a/worklenz-frontend/src/features/i18n/localesSlice.ts b/worklenz-frontend/src/features/i18n/localesSlice.ts index 045f385e..9177ad70 100644 --- a/worklenz-frontend/src/features/i18n/localesSlice.ts +++ b/worklenz-frontend/src/features/i18n/localesSlice.ts @@ -7,6 +7,7 @@ export enum Language { PT = 'pt', ALB = 'alb', DE = 'de', + ZH_CN = 'zh_cn', } export type ILanguageType = `${Language}`; diff --git a/worklenz-frontend/src/features/project/project-drawer.slice.ts b/worklenz-frontend/src/features/project/project-drawer.slice.ts index a341497d..15fcc47a 100644 --- a/worklenz-frontend/src/features/project/project-drawer.slice.ts +++ b/worklenz-frontend/src/features/project/project-drawer.slice.ts @@ -20,10 +20,31 @@ export const fetchProjectData = createAsyncThunk( 'project/fetchProjectData', async (projectId: string, { rejectWithValue, dispatch }) => { try { + if (!projectId) { + throw new Error('Project ID is required'); + } + + console.log(`Fetching project data for ID: ${projectId}`); const response = await projectsApiService.getProject(projectId); + + if (!response) { + throw new Error('No response received from API'); + } + + if (!response.done) { + throw new Error(response.message || 'API request failed'); + } + + if (!response.body) { + throw new Error('No project data in response body'); + } + + console.log(`Successfully fetched project data:`, response.body); return response.body; } catch (error) { - return rejectWithValue(error instanceof Error ? error.message : 'Failed to fetch project'); + const errorMessage = error instanceof Error ? error.message : 'Failed to fetch project'; + console.error(`Error fetching project data for ID ${projectId}:`, error); + return rejectWithValue(errorMessage); } } ); @@ -44,16 +65,21 @@ const projectDrawerSlice = createSlice({ }, extraReducers: builder => { builder - .addCase(fetchProjectData.pending, state => { + console.log('Starting project data fetch...'); state.projectLoading = true; + state.project = null; // Clear existing data while loading }) .addCase(fetchProjectData.fulfilled, (state, action) => { + console.log('Project data fetch completed successfully:', action.payload); state.project = action.payload; state.projectLoading = false; }) - .addCase(fetchProjectData.rejected, state => { + .addCase(fetchProjectData.rejected, (state, action) => { + console.error('Project data fetch failed:', action.payload); state.projectLoading = false; + state.project = null; + // You could add an error field to the state if needed for UI feedback }); }, }); diff --git a/worklenz-frontend/src/features/task-management/grouping.slice.ts b/worklenz-frontend/src/features/task-management/grouping.slice.ts index b9445c13..cea2c047 100644 --- a/worklenz-frontend/src/features/task-management/grouping.slice.ts +++ b/worklenz-frontend/src/features/task-management/grouping.slice.ts @@ -109,7 +109,14 @@ export const selectCurrentGrouping = (state: RootState) => state.grouping.curren export const selectCustomPhases = (state: RootState) => state.grouping.customPhases; export const selectGroupOrder = (state: RootState) => state.grouping.groupOrder; export const selectGroupStates = (state: RootState) => state.grouping.groupStates; -export const selectCollapsedGroups = (state: RootState) => new Set(state.grouping.collapsedGroups); +export const selectCollapsedGroupsArray = (state: RootState) => state.grouping.collapsedGroups; + +// Memoized selector to prevent unnecessary re-renders +export const selectCollapsedGroups = createSelector( + [selectCollapsedGroupsArray], + (collapsedGroupsArray) => new Set(collapsedGroupsArray) +); + export const selectIsGroupCollapsed = (state: RootState, groupId: string) => state.grouping.collapsedGroups.includes(groupId); diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index 08febf40..9a41b589 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -5,6 +5,7 @@ import { createAsyncThunk, EntityState, EntityId, + createSelector, } from '@reduxjs/toolkit'; import { Task, TaskManagementState, TaskGroup, TaskGrouping } from '@/types/task-management.types'; import { ITaskListColumn } from '@/types/tasks/taskList.types'; @@ -1142,7 +1143,12 @@ export const { // Export the selectors export const selectAllTasks = (state: RootState) => state.taskManagement.entities; -export const selectAllTasksArray = (state: RootState) => Object.values(state.taskManagement.entities); + +// Memoized selector to prevent unnecessary re-renders +export const selectAllTasksArray = createSelector( + [selectAllTasks], + (entities) => Object.values(entities) +); export const selectTaskById = (state: RootState, taskId: string) => state.taskManagement.entities[taskId]; export const selectTaskIds = (state: RootState) => state.taskManagement.ids; export const selectGroups = (state: RootState) => state.taskManagement.groups; @@ -1153,15 +1159,21 @@ export const selectSelectedPriorities = (state: RootState) => state.taskManageme export const selectSearch = (state: RootState) => state.taskManagement.search; export const selectSubtaskLoading = (state: RootState, taskId: string) => state.taskManagement.loadingSubtasks[taskId] || false; -// Memoized selectors -export const selectTasksByStatus = (state: RootState, status: string) => - Object.values(state.taskManagement.entities).filter(task => task.status === status); +// Memoized selectors to prevent unnecessary re-renders +export const selectTasksByStatus = createSelector( + [selectAllTasksArray, (_state: RootState, status: string) => status], + (tasks, status) => tasks.filter(task => task.status === status) +); -export const selectTasksByPriority = (state: RootState, priority: string) => - Object.values(state.taskManagement.entities).filter(task => task.priority === priority); +export const selectTasksByPriority = createSelector( + [selectAllTasksArray, (_state: RootState, priority: string) => priority], + (tasks, priority) => tasks.filter(task => task.priority === priority) +); -export const selectTasksByPhase = (state: RootState, phase: string) => - Object.values(state.taskManagement.entities).filter(task => task.phase === phase); +export const selectTasksByPhase = createSelector( + [selectAllTasksArray, (_state: RootState, phase: string) => phase], + (tasks, phase) => tasks.filter(task => task.phase === phase) +); // Add archived selector export const selectArchived = (state: RootState) => state.taskManagement.archived; diff --git a/worklenz-frontend/src/hooks/useTranslationPreloader.ts b/worklenz-frontend/src/hooks/useTranslationPreloader.ts index 7d2ae56c..46b3ad86 100644 --- a/worklenz-frontend/src/hooks/useTranslationPreloader.ts +++ b/worklenz-frontend/src/hooks/useTranslationPreloader.ts @@ -26,7 +26,7 @@ export const useTranslationPreloader = ( try { setIsLoading(true); - // Ensure translations are loaded + // Only load translations for current language to avoid multiple requests await ensureTranslationsLoaded(namespaces); // Wait for i18next to be ready @@ -47,12 +47,18 @@ export const useTranslationPreloader = ( } }; - loadTranslations(); + // Only load if not already loaded + if (!isLoaded && !ready) { + loadTranslations(); + } else if (ready && !isLoaded) { + setIsLoaded(true); + setIsLoading(false); + } return () => { isMounted = false; }; - }, [namespaces, ready]); + }, [namespaces, ready, isLoaded]); return { t, diff --git a/worklenz-frontend/src/i18n.ts b/worklenz-frontend/src/i18n.ts index 7c336dd0..8c96cd62 100644 --- a/worklenz-frontend/src/i18n.ts +++ b/worklenz-frontend/src/i18n.ts @@ -6,12 +6,27 @@ import logger from './utils/errorLogger'; // Essential namespaces that should be preloaded to prevent Suspense const ESSENTIAL_NAMESPACES = [ 'common', + 'auth/login', + 'navbar', +]; + +// Secondary namespaces that can be loaded on demand +const SECONDARY_NAMESPACES = [ 'tasks/task-table-bulk-actions', 'task-management', - 'auth/login', 'settings', + 'home', + 'project-drawer', ]; +// Cache to track loaded translations and prevent duplicate requests +const loadedTranslations = new Set(); +const loadingPromises = new Map>(); + +// Background loading queue for non-essential translations +let backgroundLoadingQueue: Array<{ lang: string; ns: string }> = []; +let isBackgroundLoading = false; + i18n .use(HttpApi) .use(initReactI18next) @@ -19,51 +34,92 @@ i18n fallbackLng: 'en', backend: { loadPath: '/locales/{{lng}}/{{ns}}.json', + // Add request timeout to prevent hanging on slow connections + requestOptions: { + cache: 'default', + mode: 'cors', + credentials: 'same-origin', + }, }, defaultNS: 'common', + // Only load essential namespaces initially ns: ESSENTIAL_NAMESPACES, interpolation: { escapeValue: false, }, - // Preload essential namespaces - preload: ['en', 'es', 'pt', 'alb', 'de'], - // Load all namespaces on initialization + // Only preload current language to reduce initial load + preload: [], load: 'languageOnly', - // Cache translations + // Disable loading all namespaces on init + initImmediate: false, + // Cache translations with shorter expiration for better performance cache: { enabled: true, - expirationTime: 24 * 60 * 60 * 1000, // 24 hours + expirationTime: 12 * 60 * 60 * 1000, // 12 hours }, + // Reduce debug output in production + debug: process.env.NODE_ENV === 'development', }); -// Utility function to ensure translations are loaded -export const ensureTranslationsLoaded = async (namespaces: string[] = ESSENTIAL_NAMESPACES) => { - const currentLang = i18n.language || 'en'; - +// Optimized function to ensure translations are loaded +export const ensureTranslationsLoaded = async ( + namespaces: string[] = ESSENTIAL_NAMESPACES, + languages: string[] = [i18n.language || 'en'] +) => { try { - // Load all essential namespaces for the current language - await Promise.all( - namespaces.map(ns => - i18n.loadNamespaces(ns).catch(() => { - logger.error(`Failed to load namespace: ${ns}`); - }) - ) - ); + const loadPromises: Promise[] = []; - // Also preload for other languages to prevent delays on language switch - const otherLangs = ['en', 'es', 'pt', 'alb', 'de'].filter(lang => lang !== currentLang); - await Promise.all( - otherLangs.map(lang => - Promise.all( - namespaces.map(ns => - i18n.loadNamespaces(ns).catch(() => { - logger.error(`Failed to load namespace: ${ns}`); - }) - ) - ) - ) - ); + for (const lang of languages) { + for (const ns of namespaces) { + const key = `${lang}:${ns}`; + + // Skip if already loaded + if (loadedTranslations.has(key)) { + continue; + } + // Check if already loading + if (loadingPromises.has(key)) { + loadPromises.push(loadingPromises.get(key)!); + continue; + } + + // Create loading promise + const loadingPromise = new Promise((resolve, reject) => { + const currentLang = i18n.language; + const shouldSwitchLang = currentLang !== lang; + + const loadForLanguage = async () => { + try { + if (shouldSwitchLang) { + await i18n.changeLanguage(lang); + } + + await i18n.loadNamespaces(ns); + + if (shouldSwitchLang && currentLang) { + await i18n.changeLanguage(currentLang); + } + + loadedTranslations.add(key); + resolve(); + } catch (error) { + logger.error(`Failed to load namespace: ${ns} for language: ${lang}`, error); + reject(error); + } finally { + loadingPromises.delete(key); + } + }; + + loadForLanguage(); + }); + + loadingPromises.set(key, loadingPromise); + loadPromises.push(loadingPromise); + } + } + + await Promise.all(loadPromises); return true; } catch (error) { logger.error('Failed to load translations:', error); @@ -71,7 +127,86 @@ export const ensureTranslationsLoaded = async (namespaces: string[] = ESSENTIAL_ } }; -// Initialize translations on app startup -ensureTranslationsLoaded(); +// Background loading function for non-essential translations +const processBackgroundQueue = async () => { + if (isBackgroundLoading || backgroundLoadingQueue.length === 0) return; + + isBackgroundLoading = true; + + try { + // Process queue in batches to avoid overwhelming the network + const batchSize = 3; + while (backgroundLoadingQueue.length > 0) { + const batch = backgroundLoadingQueue.splice(0, batchSize); + const batchPromises = batch.map(({ lang, ns }) => + ensureTranslationsLoaded([ns], [lang]).catch(error => { + logger.error(`Background loading failed for ${lang}:${ns}`, error); + }) + ); + + await Promise.all(batchPromises); + + // Add small delay between batches to prevent blocking + if (backgroundLoadingQueue.length > 0) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + } finally { + isBackgroundLoading = false; + } +}; + +// Queue secondary translations for background loading +const queueSecondaryTranslations = (language: string) => { + SECONDARY_NAMESPACES.forEach(ns => { + const key = `${language}:${ns}`; + if (!loadedTranslations.has(key)) { + backgroundLoadingQueue.push({ lang: language, ns }); + } + }); + + // Start background loading with a delay to not interfere with initial render + setTimeout(processBackgroundQueue, 2000); +}; + +// Initialize only essential translations for current language +const initializeTranslations = async () => { + try { + const currentLang = i18n.language || 'en'; + + // Load only essential namespaces initially + await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [currentLang]); + + // Queue secondary translations for background loading + queueSecondaryTranslations(currentLang); + + return true; + } catch (error) { + logger.error('Failed to initialize translations:', error); + return false; + } +}; + +// Language change handler that prioritizes essential namespaces +export const changeLanguageOptimized = async (language: string) => { + try { + // Change language first + await i18n.changeLanguage(language); + + // Load essential namespaces immediately + await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [language]); + + // Queue secondary translations for background loading + queueSecondaryTranslations(language); + + return true; + } catch (error) { + logger.error(`Failed to change language to ${language}:`, error); + return false; + } +}; + +// Initialize translations on app startup (only essential ones) +initializeTranslations(); export default i18n; diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index bf6348a3..289b98c5 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -1,8 +1,8 @@ import React, { ReactNode, Suspense } from 'react'; import { InlineSuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; +import i18n from '@/i18n'; // Import core components synchronously to avoid suspense in main tabs -import ProjectViewEnhancedTasks from '@/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks'; import ProjectViewEnhancedBoard from '@/pages/projects/projectView/enhancedBoard/project-view-enhanced-board'; import TaskListV2 from '@/components/task-list-v2/TaskListV2'; @@ -29,26 +29,31 @@ type TabItems = { element: ReactNode; }; +// Function to get translated labels +const getTabLabel = (key: string): string => { + return i18n.t(`project-view:${key}`); +}; + // settings all element items use for tabs export const tabItems: TabItems[] = [ { index: 0, key: 'tasks-list', - label: 'Task List', + label: getTabLabel('taskList'), isPinned: true, element: React.createElement(TaskListV2), }, { index: 1, key: 'board', - label: 'Board', + label: getTabLabel('board'), isPinned: true, element: React.createElement(ProjectViewEnhancedBoard), }, { index: 2, key: 'project-insights-member-overview', - label: 'Insights', + label: getTabLabel('insights'), element: React.createElement( Suspense, { fallback: React.createElement(InlineSuspenseFallback) }, @@ -58,7 +63,7 @@ export const tabItems: TabItems[] = [ { index: 3, key: 'all-attachments', - label: 'Files', + label: getTabLabel('files'), element: React.createElement( Suspense, { fallback: React.createElement(InlineSuspenseFallback) }, @@ -68,7 +73,7 @@ export const tabItems: TabItems[] = [ { index: 4, key: 'members', - label: 'Members', + label: getTabLabel('members'), element: React.createElement( Suspense, { fallback: React.createElement(InlineSuspenseFallback) }, @@ -78,7 +83,7 @@ export const tabItems: TabItems[] = [ { index: 5, key: 'updates', - label: 'Updates', + label: getTabLabel('updates'), element: React.createElement( Suspense, { fallback: React.createElement(InlineSuspenseFallback) }, @@ -86,3 +91,29 @@ export const tabItems: TabItems[] = [ ), }, ]; + +// Function to update tab labels when language changes +export const updateTabLabels = () => { + tabItems.forEach(item => { + switch (item.key) { + case 'tasks-list': + item.label = getTabLabel('taskList'); + break; + case 'board': + item.label = getTabLabel('board'); + break; + case 'project-insights-member-overview': + item.label = getTabLabel('insights'); + break; + case 'all-attachments': + item.label = getTabLabel('files'); + break; + case 'members': + item.label = getTabLabel('members'); + break; + case 'updates': + item.label = getTabLabel('updates'); + break; + } + }); +}; diff --git a/worklenz-frontend/src/pages/home/home-page.tsx b/worklenz-frontend/src/pages/home/home-page.tsx index 6186fbcb..72d06bf6 100644 --- a/worklenz-frontend/src/pages/home/home-page.tsx +++ b/worklenz-frontend/src/pages/home/home-page.tsx @@ -26,7 +26,7 @@ const TASK_LIST_MIN_WIDTH = 500; const SIDEBAR_MAX_WIDTH = 400; // Lazy load heavy components -const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer')); +const TaskDrawer = React.lazy(() => import('@/components/task-drawer/task-drawer')); const HomePage = memo(() => { const dispatch = useAppDispatch(); @@ -35,6 +35,19 @@ const HomePage = memo(() => { useDocumentTitle('Home'); + // Preload TaskDrawer component to prevent dynamic import failures + useEffect(() => { + const preloadTaskDrawer = async () => { + try { + await import('@/components/task-drawer/task-drawer'); + } catch (error) { + console.warn('Failed to preload TaskDrawer:', error); + } + }; + + preloadTaskDrawer(); + }, []); + // Memoize fetch function to prevent recreation on every render const fetchLookups = useCallback(async () => { const fetchPromises = [ @@ -113,9 +126,15 @@ const HomePage = memo(() => { {MainContent} - {/* Use Suspense for lazy-loaded components */} - - {createPortal(, document.body, 'home-task-drawer')} + {/* Use Suspense for lazy-loaded components with error boundary */} + Loading...
}> + {createPortal( + + + , + document.body, + 'home-task-drawer' + )} {createPortal( diff --git a/worklenz-frontend/src/pages/projects/project-list.tsx b/worklenz-frontend/src/pages/projects/project-list.tsx index 7ea79ba7..e6df7839 100644 --- a/worklenz-frontend/src/pages/projects/project-list.tsx +++ b/worklenz-frontend/src/pages/projects/project-list.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { ProjectViewType, ProjectGroupBy } from '@/types/project/project.types'; @@ -85,6 +85,10 @@ const createFilters = (items: { id: string; name: string }[]) => const ProjectList: React.FC = () => { const [filteredInfo, setFilteredInfo] = useState>({}); const [isLoading, setIsLoading] = useState(false); + const [searchValue, setSearchValue] = useState(''); + const searchTimeoutRef = useRef(null); + const lastQueryParamsRef = useRef(''); + const [errorMessage, setErrorMessage] = useState(null); const { t } = useTranslation('all-project-list'); const dispatch = useAppDispatch(); @@ -103,12 +107,130 @@ const ProjectList: React.FC = () => { const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer); const { filteredCategories, filteredStatuses } = useAppSelector(state => state.projectsReducer); + // Optimize query parameters to prevent unnecessary re-renders + const optimizedQueryParams = useMemo(() => { + const params = { + index: requestParams.index, + size: requestParams.size, + field: requestParams.field, + order: requestParams.order, + search: requestParams.search, + filter: requestParams.filter, + statuses: requestParams.statuses, + categories: requestParams.categories, + }; + + // Create a stable key for comparison + const paramsKey = JSON.stringify(params); + + // Only return new params if they've actually changed + if (paramsKey !== lastQueryParamsRef.current) { + lastQueryParamsRef.current = paramsKey; + return params; + } + + return params; + }, [requestParams]); + + // Use the optimized query with better error handling and caching const { data: projectsData, isLoading: loadingProjects, isFetching: isFetchingProjects, refetch: refetchProjects, - } = useGetProjectsQuery(requestParams); + error: projectsError, + } = useGetProjectsQuery(optimizedQueryParams, { + // Enable caching and reduce unnecessary refetches + refetchOnMountOrArgChange: 30, // Refetch if data is older than 30 seconds + refetchOnFocus: false, // Don't refetch on window focus + refetchOnReconnect: true, // Refetch on network reconnect + // Skip query if we're in group view mode + skip: viewMode === ProjectViewType.GROUP, + }); + + // Add performance monitoring + const performanceRef = useRef<{ startTime: number | null }>({ startTime: null }); + + // Monitor query performance + useEffect(() => { + if (loadingProjects && !performanceRef.current.startTime) { + performanceRef.current.startTime = performance.now(); + } else if (!loadingProjects && performanceRef.current.startTime) { + const duration = performance.now() - performanceRef.current.startTime; + console.log(`Projects query completed in ${duration.toFixed(2)}ms`); + performanceRef.current.startTime = null; + } + }, [loadingProjects]); + + // Optimized debounced search with better cleanup and performance + const debouncedSearch = useCallback( + debounce((searchTerm: string) => { + console.log('Executing debounced search:', searchTerm); + + // Clear any error messages when starting a new search + setErrorMessage(null); + + if (viewMode === ProjectViewType.LIST) { + dispatch(setRequestParams({ + search: searchTerm, + index: 1 // Reset to first page on search + })); + } else if (viewMode === ProjectViewType.GROUP) { + const newGroupedParams = { + ...groupedRequestParams, + search: searchTerm, + index: 1, + }; + dispatch(setGroupedRequestParams(newGroupedParams)); + + // Add timeout for grouped search to prevent rapid API calls + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + searchTimeoutRef.current = setTimeout(() => { + dispatch(fetchGroupedProjects(newGroupedParams)); + }, 100); + } + }, 500), // Increased debounce time for better performance + [dispatch, viewMode, groupedRequestParams] + ); + + // Enhanced cleanup with better timeout management + useEffect(() => { + return () => { + debouncedSearch.cancel(); + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + searchTimeoutRef.current = null; + } + }; + }, [debouncedSearch]); + + // Improved search change handler with better validation + const handleSearchChange = useCallback( + (e: React.ChangeEvent) => { + const newSearchValue = e.target.value; + + // Validate input length to prevent excessive API calls + if (newSearchValue.length > 100) { + return; // Prevent extremely long search terms + } + + setSearchValue(newSearchValue); + trackMixpanelEvent(evt_projects_search); + + // Clear any existing timeout + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + searchTimeoutRef.current = null; + } + + // Debounce the actual search execution + debouncedSearch(newSearchValue); + }, + [debouncedSearch, trackMixpanelEvent] + ); const getFilterIndex = useCallback(() => { return +(localStorage.getItem(FILTER_INDEX_KEY) || 0); @@ -247,8 +369,54 @@ const ProjectList: React.FC = () => { // Memoize the table data source const tableDataSource = useMemo(() => projectsData?.body?.data || [], [projectsData?.body?.data]); - // Memoize the empty text component - const emptyText = useMemo(() => , [t]); + // Handle query errors + useEffect(() => { + if (projectsError) { + console.error('Projects query error:', projectsError); + setErrorMessage('Failed to load projects. Please try again.'); + } else { + setErrorMessage(null); + } + }, [projectsError]); + + // Optimized refresh handler with better error handling + const handleRefresh = useCallback(async () => { + try { + trackMixpanelEvent(evt_projects_refresh_click); + setIsLoading(true); + setErrorMessage(null); + + if (viewMode === ProjectViewType.LIST) { + await refetchProjects(); + } else if (viewMode === ProjectViewType.GROUP && groupBy) { + await dispatch(fetchGroupedProjects(groupedRequestParams)).unwrap(); + } + } catch (error) { + console.error('Error refreshing projects:', error); + setErrorMessage('Failed to refresh projects. Please try again.'); + } finally { + setIsLoading(false); + } + }, [trackMixpanelEvent, refetchProjects, viewMode, groupBy, dispatch, groupedRequestParams]); + + // Enhanced empty text with error handling + const emptyContent = useMemo(() => { + if (errorMessage) { + return ( + +

{errorMessage}

+ +
+ } + /> + ); + } + return ; + }, [errorMessage, handleRefresh, isLoading, t]); // Memoize the pagination show total function const paginationShowTotal = useMemo( @@ -262,135 +430,108 @@ const ProjectList: React.FC = () => { filters: Record, sorter: SorterResult | SorterResult[] ) => { - const newParams: Partial = {}; - if (!filters?.status_id) { - newParams.statuses = null; - dispatch(setFilteredStatuses([])); - } else { - newParams.statuses = filters.status_id.join(' '); + // Batch all parameter updates to reduce re-renders + const updates: Partial = {}; + let hasChanges = false; + + // Handle status filters + if (filters?.status_id !== filteredInfo.status_id) { + if (!filters?.status_id) { + updates.statuses = null; + dispatch(setFilteredStatuses([])); + } else { + updates.statuses = filters.status_id.join(' '); + } + hasChanges = true; } - if (!filters?.category_id) { - newParams.categories = null; - dispatch(setFilteredCategories([])); - } else { - newParams.categories = filters.category_id.join(' '); + // Handle category filters + if (filters?.category_id !== filteredInfo.category_id) { + if (!filters?.category_id) { + updates.categories = null; + dispatch(setFilteredCategories([])); + } else { + updates.categories = filters.category_id.join(' '); + } + hasChanges = true; } + // Handle sorting const newOrder = Array.isArray(sorter) ? sorter[0].order : sorter.order; const newField = (Array.isArray(sorter) ? sorter[0].columnKey : sorter.columnKey) as string; - if (newOrder && newField) { - newParams.order = newOrder ?? 'ascend'; - newParams.field = newField ?? 'name'; - setSortingValues(newParams.field, newParams.order); + if (newOrder && newField && (newOrder !== requestParams.order || newField !== requestParams.field)) { + updates.order = newOrder ?? 'ascend'; + updates.field = newField ?? 'name'; + setSortingValues(updates.field, updates.order); + hasChanges = true; } - newParams.index = newPagination.current || 1; - newParams.size = newPagination.pageSize || DEFAULT_PAGE_SIZE; + // Handle pagination + if (newPagination.current !== requestParams.index || newPagination.pageSize !== requestParams.size) { + updates.index = newPagination.current || 1; + updates.size = newPagination.pageSize || DEFAULT_PAGE_SIZE; + hasChanges = true; + } - dispatch(setRequestParams(newParams)); + // Only dispatch if there are actual changes + if (hasChanges) { + dispatch(setRequestParams(updates)); - // Also update grouped request params to keep them in sync - dispatch( - setGroupedRequestParams({ - ...groupedRequestParams, - statuses: newParams.statuses, - categories: newParams.categories, - order: newParams.order, - field: newParams.field, - index: newParams.index, - size: newParams.size, - }) - ); + // Also update grouped request params to keep them in sync + dispatch( + setGroupedRequestParams({ + ...groupedRequestParams, + ...updates, + }) + ); + } setFilteredInfo(filters); }, - [dispatch, setSortingValues, groupedRequestParams] + [dispatch, setSortingValues, groupedRequestParams, filteredInfo, requestParams] ); + // Optimized grouped table change handler const handleGroupedTableChange = useCallback( (newPagination: TablePaginationConfig) => { const newParams: Partial = { index: newPagination.current || 1, size: newPagination.pageSize || DEFAULT_PAGE_SIZE, }; - dispatch(setGroupedRequestParams(newParams)); + + // Only update if values actually changed + if (newParams.index !== groupedRequestParams.index || newParams.size !== groupedRequestParams.size) { + dispatch(setGroupedRequestParams(newParams)); + } }, [dispatch, groupedRequestParams] ); - const handleRefresh = useCallback(() => { - trackMixpanelEvent(evt_projects_refresh_click); - if (viewMode === ProjectViewType.LIST) { - refetchProjects(); - } else if (viewMode === ProjectViewType.GROUP && groupBy) { - dispatch(fetchGroupedProjects(groupedRequestParams)); - } - }, [trackMixpanelEvent, refetchProjects, viewMode, groupBy, dispatch, groupedRequestParams]); - + // Optimized segment change handler with better state management const handleSegmentChange = useCallback( (value: IProjectFilter) => { const newFilterIndex = filters.indexOf(value); setFilterIndex(newFilterIndex); - // Update both request params for consistency - dispatch(setRequestParams({ filter: newFilterIndex })); - dispatch( - setGroupedRequestParams({ - ...groupedRequestParams, - filter: newFilterIndex, - index: 1, // Reset to first page when changing filter - }) - ); + // Batch updates to reduce re-renders + const baseUpdates = { filter: newFilterIndex, index: 1 }; + + dispatch(setRequestParams(baseUpdates)); + dispatch(setGroupedRequestParams({ + ...groupedRequestParams, + ...baseUpdates, + })); - // Refresh data based on current view mode - if (viewMode === ProjectViewType.LIST) { - refetchProjects(); - } else if (viewMode === ProjectViewType.GROUP && groupBy) { - dispatch( - fetchGroupedProjects({ - ...groupedRequestParams, - filter: newFilterIndex, - index: 1, - }) - ); + // Only trigger data fetch for group view (list view will auto-refetch via query) + if (viewMode === ProjectViewType.GROUP && groupBy) { + dispatch(fetchGroupedProjects({ + ...groupedRequestParams, + ...baseUpdates, + })); } }, - [filters, setFilterIndex, dispatch, refetchProjects, viewMode, groupBy, groupedRequestParams] - ); - - // Debounced search for grouped projects - const debouncedGroupedSearch = useCallback( - debounce((params: typeof groupedRequestParams) => { - if (groupBy) { - dispatch(fetchGroupedProjects(params)); - } - }, 300), - [dispatch, groupBy] - ); - - const handleSearchChange = useCallback( - (e: React.ChangeEvent) => { - const searchValue = e.target.value; - trackMixpanelEvent(evt_projects_search); - - // Update both request params for consistency - dispatch(setRequestParams({ search: searchValue, index: 1 })); - - if (viewMode === ProjectViewType.GROUP) { - const newGroupedParams = { - ...groupedRequestParams, - search: searchValue, - index: 1, - }; - dispatch(setGroupedRequestParams(newGroupedParams)); - - // Trigger debounced search in group mode - debouncedGroupedSearch(newGroupedParams); - } - }, - [dispatch, trackMixpanelEvent, viewMode, groupedRequestParams, debouncedGroupedSearch] + [filters, setFilterIndex, dispatch, groupedRequestParams, viewMode, groupBy] ); const handleViewToggle = useCallback( @@ -557,52 +698,113 @@ const ProjectList: React.FC = () => { ] ); - useEffect(() => { - if (viewMode === ProjectViewType.LIST) { - setIsLoading(loadingProjects || isFetchingProjects); - } else { - setIsLoading(groupedProjects.loading); - } - }, [loadingProjects, isFetchingProjects, viewMode, groupedProjects.loading]); - + // Optimize useEffect hooks to reduce unnecessary API calls useEffect(() => { const filterIndex = getFilterIndex(); - dispatch(setRequestParams({ filter: filterIndex })); - // Also sync with grouped request params on initial load - dispatch( - setGroupedRequestParams({ + const initialParams = { filter: filterIndex }; + + // Only update if values are different + if (requestParams.filter !== filterIndex) { + dispatch(setRequestParams(initialParams)); + } + + // Initialize grouped request params with proper groupBy value + if (!groupedRequestParams.groupBy) { + const initialGroupBy = groupBy || ProjectGroupBy.CATEGORY; + dispatch(setGroupedRequestParams({ filter: filterIndex, index: 1, size: DEFAULT_PAGE_SIZE, field: 'name', order: 'ascend', search: '', - groupBy: '', + groupBy: initialGroupBy, statuses: null, categories: null, - }) - ); - }, [dispatch, getFilterIndex]); + })); + } + }, [dispatch, getFilterIndex, groupBy]); // Add groupBy to deps to handle initial state + // Separate effect for tracking page visits - only run once useEffect(() => { trackMixpanelEvent(evt_projects_page_visit); - if (viewMode === ProjectViewType.LIST) { - refetchProjects(); - } - }, [requestParams, refetchProjects, trackMixpanelEvent, viewMode]); + }, [trackMixpanelEvent]); - // Separate useEffect for grouped projects + // Enhanced effect for grouped projects - fetch data when in group view useEffect(() => { + // Fetch grouped projects when: + // 1. View mode is GROUP + // 2. We have a groupBy value (either from Redux or default) if (viewMode === ProjectViewType.GROUP && groupBy) { - dispatch(fetchGroupedProjects(groupedRequestParams)); + // Always ensure grouped request params are properly set with current groupBy + const shouldUpdateParams = !groupedRequestParams.groupBy || groupedRequestParams.groupBy !== groupBy; + + if (shouldUpdateParams) { + const updatedParams = { + ...groupedRequestParams, + groupBy: groupBy, + // Ensure we have all required params for the API call + index: groupedRequestParams.index || 1, + size: groupedRequestParams.size || DEFAULT_PAGE_SIZE, + field: groupedRequestParams.field || 'name', + order: groupedRequestParams.order || 'ascend', + }; + dispatch(setGroupedRequestParams(updatedParams)); + dispatch(fetchGroupedProjects(updatedParams)); + } else if (groupedRequestParams.groupBy === groupBy && !groupedProjects.data) { + // Params are set correctly but we don't have data yet - fetch it + dispatch(fetchGroupedProjects(groupedRequestParams)); + } } - }, [dispatch, viewMode, groupBy, groupedRequestParams]); + }, [dispatch, viewMode, groupBy, groupedRequestParams, groupedProjects.data]); + // Optimize lookups loading - only fetch once useEffect(() => { - if (projectStatuses.length === 0) dispatch(fetchProjectStatuses()); - if (projectCategories.length === 0) dispatch(fetchProjectCategories()); - if (projectHealths.length === 0) dispatch(fetchProjectHealth()); - }, [dispatch, projectStatuses.length, projectCategories.length, projectHealths.length]); + const loadLookups = async () => { + const promises = []; + + if (projectStatuses.length === 0) { + promises.push(dispatch(fetchProjectStatuses())); + } + if (projectCategories.length === 0) { + promises.push(dispatch(fetchProjectCategories())); + } + if (projectHealths.length === 0) { + promises.push(dispatch(fetchProjectHealth())); + } + + // Load all lookups in parallel + if (promises.length > 0) { + await Promise.allSettled(promises); + } + }; + + loadLookups(); + }, [dispatch]); // Remove length dependencies to avoid re-runs + + // Sync search input value with Redux state + useEffect(() => { + const currentSearch = viewMode === ProjectViewType.LIST ? requestParams.search : groupedRequestParams.search; + if (searchValue !== currentSearch) { + setSearchValue(currentSearch || ''); + } + }, [requestParams.search, groupedRequestParams.search, viewMode, searchValue]); + + // Optimize loading state management + useEffect(() => { + let newLoadingState = false; + + if (viewMode === ProjectViewType.LIST) { + newLoadingState = loadingProjects || isFetchingProjects; + } else { + newLoadingState = groupedProjects.loading; + } + + // Only update if loading state actually changed + if (isLoading !== newLoadingState) { + setIsLoading(newLoadingState); + } + }, [loadingProjects, isFetchingProjects, viewMode, groupedProjects.loading, isLoading]); return (
@@ -638,9 +840,14 @@ const ProjectList: React.FC = () => { placeholder={t('placeholder')} suffix={} type="text" - value={requestParams.search} + value={searchValue} onChange={handleSearchChange} aria-label="Search projects" + allowClear + onClear={() => { + setSearchValue(''); + debouncedSearch(''); + }} /> {isOwnerOrAdmin && } @@ -657,7 +864,7 @@ const ProjectList: React.FC = () => { size="small" onChange={handleTableChange} pagination={paginationConfig} - locale={{ emptyText }} + locale={{ emptyText: emptyContent }} onRow={record => ({ onClick: () => navigateToProject(record.id, record.team_member_default_view), onMouseEnter: () => handleProjectHover(record.id), diff --git a/worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx b/worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx deleted file mode 100644 index 63631612..00000000 --- a/worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx +++ /dev/null @@ -1,582 +0,0 @@ -import { useEffect, useState, useRef, useMemo, useCallback } from 'react'; -import { useAppSelector } from '@/hooks/useAppSelector'; - -import TaskListFilters from '../taskList/task-list-filters/task-list-filters'; -import { Flex, Skeleton } from 'antd'; -import BoardSectionCardContainer from './board-section/board-section-container'; -import { - fetchBoardTaskGroups, - reorderTaskGroups, - moveTaskBetweenGroups, - IGroupBy, - updateTaskProgress, -} from '@features/board/board-slice'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { - DndContext, - DragEndEvent, - DragOverEvent, - DragStartEvent, - closestCenter, - DragOverlay, - MouseSensor, - TouchSensor, - useSensor, - useSensors, - getFirstCollision, - pointerWithin, - rectIntersection, - UniqueIdentifier, -} from '@dnd-kit/core'; -import BoardViewTaskCard from './board-section/board-task-card/board-view-task-card'; -import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; -import useTabSearchParam from '@/hooks/useTabSearchParam'; -import { useSocket } from '@/socket/socketContext'; -import { useAuthService } from '@/hooks/useAuth'; -import { SocketEvents } from '@/shared/socket-events'; -import alertService from '@/services/alerts/alertService'; -import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; -import { - evt_project_board_visit, - evt_project_task_list_drag_and_move, -} from '@/shared/worklenz-analytics-events'; -import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request'; -import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; -import logger from '@/utils/errorLogger'; -import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status'; -import { debounce } from 'lodash'; -import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types'; -import { updateTaskPriority as updateBoardTaskPriority } from '@/features/board/board-slice'; - -interface DroppableContainer { - id: UniqueIdentifier; - data: { - current?: { - type?: string; - }; - }; -} - -const ProjectViewBoard = () => { - const dispatch = useAppDispatch(); - const { projectView } = useTabSearchParam(); - const { socket } = useSocket(); - const authService = useAuthService(); - const currentSession = authService.getCurrentSession(); - const { trackMixpanelEvent } = useMixpanelTracking(); - const [currentTaskIndex, setCurrentTaskIndex] = useState(-1); - // Add local loading state to immediately show skeleton - const [isLoading, setIsLoading] = useState(true); - - const { projectId } = useAppSelector(state => state.projectReducer); - const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector( - state => state.boardReducer - ); - const { statusCategories, loading: loadingStatusCategories } = useAppSelector( - state => state.taskStatusReducer - ); - const [activeItem, setActiveItem] = useState(null); - - // Store the original source group ID when drag starts - const originalSourceGroupIdRef = useRef(null); - const lastOverId = useRef(null); - const recentlyMovedToNewContainer = useRef(false); - const [clonedItems, setClonedItems] = useState(null); - const isDraggingRef = useRef(false); - - // Update loading state based on all loading conditions - useEffect(() => { - setIsLoading(loadingGroups || loadingStatusCategories); - }, [loadingGroups, loadingStatusCategories]); - - // Load data efficiently with async/await and Promise.all - useEffect(() => { - const loadData = async () => { - if (projectId && groupBy && projectView === 'kanban') { - const promises = []; - - if (!loadingGroups) { - promises.push(dispatch(fetchBoardTaskGroups(projectId))); - } - - if (!statusCategories.length) { - promises.push(dispatch(fetchStatusesCategories())); - } - - // Wait for all data to load - await Promise.all(promises); - } - }; - - loadData(); - }, [dispatch, projectId, groupBy, projectView, search, archived]); - - // Create sensors with memoization to prevent unnecessary re-renders - const sensors = useSensors( - useSensor(MouseSensor, { - activationConstraint: { - distance: 10, - delay: 100, - tolerance: 5, - }, - }), - useSensor(TouchSensor, { - activationConstraint: { - delay: 250, - tolerance: 5, - }, - }) - ); - - const collisionDetectionStrategy = useCallback( - (args: { - active: { id: UniqueIdentifier; data: { current?: { type?: string } } }; - droppableContainers: DroppableContainer[]; - }) => { - if (activeItem?.type === 'section') { - return closestCenter({ - ...args, - droppableContainers: args.droppableContainers.filter( - (container: DroppableContainer) => container.data.current?.type === 'section' - ), - }); - } - - // Start by finding any intersecting droppable - const pointerIntersections = pointerWithin(args); - const intersections = - pointerIntersections.length > 0 ? pointerIntersections : rectIntersection(args); - let overId = getFirstCollision(intersections, 'id'); - - if (overId !== null) { - const overContainer = args.droppableContainers.find( - (container: DroppableContainer) => container.id === overId - ); - - if (overContainer?.data.current?.type === 'section') { - const containerItems = taskGroups.find(group => group.id === overId)?.tasks || []; - - if (containerItems.length > 0) { - overId = closestCenter({ - ...args, - droppableContainers: args.droppableContainers.filter( - (container: DroppableContainer) => - container.id !== overId && container.data.current?.type === 'task' - ), - })[0]?.id; - } - } - - lastOverId.current = overId; - return [{ id: overId }]; - } - - if (recentlyMovedToNewContainer.current) { - lastOverId.current = activeItem?.id; - } - - return lastOverId.current ? [{ id: lastOverId.current }] : []; - }, - [activeItem, taskGroups] - ); - - const handleTaskProgress = (data: { - id: string; - status: string; - complete_ratio: number; - completed_count: number; - total_tasks_count: number; - parent_task: string; - }) => { - dispatch(updateTaskProgress(data)); - }; - - // Debounced move task function to prevent rapid updates - const debouncedMoveTask = useCallback( - debounce( - (taskId: string, sourceGroupId: string, targetGroupId: string, targetIndex: number) => { - dispatch( - moveTaskBetweenGroups({ - taskId, - sourceGroupId, - targetGroupId, - targetIndex, - }) - ); - }, - 100 - ), - [dispatch] - ); - - const handleDragStart = (event: DragStartEvent) => { - const { active } = event; - isDraggingRef.current = true; - setActiveItem(active.data.current); - setCurrentTaskIndex(active.data.current?.sortable.index); - if (active.data.current?.type === 'task') { - originalSourceGroupIdRef.current = active.data.current.sectionId; - } - setClonedItems(taskGroups); - }; - - const findGroupForId = (id: string) => { - // If id is a sectionId - if (taskGroups.some(group => group.id === id)) return id; - // If id is a taskId, find the group containing it - const group = taskGroups.find(g => g.tasks.some(t => t.id === id)); - return group?.id; - }; - - const handleDragOver = (event: DragOverEvent) => { - try { - if (!isDraggingRef.current) return; - - const { active, over } = event; - if (!over) return; - - // Get the ids - const activeId = active.id; - const overId = over.id; - - // Find the group (section) for each - const activeGroupId = findGroupForId(activeId as string); - const overGroupId = findGroupForId(overId as string); - - // Only move if both groups exist and are different, and the active is a task - if (activeGroupId && overGroupId && active.data.current?.type === 'task') { - // Find the target index in the over group - const targetGroup = taskGroups.find(g => g.id === overGroupId); - let targetIndex = 0; - if (targetGroup) { - // If over is a task, insert before it; if over is a section, append to end - if (over.data.current?.type === 'task') { - targetIndex = targetGroup.tasks.findIndex(t => t.id === overId); - if (targetIndex === -1) targetIndex = targetGroup.tasks.length; - } else { - targetIndex = targetGroup.tasks.length; - } - } - // Use debounced move task to prevent rapid updates - debouncedMoveTask(activeId as string, activeGroupId, overGroupId, targetIndex); - } - } catch (error) { - console.error('handleDragOver error:', error); - } - }; - - const handlePriorityChange = (taskId: string, priorityId: string) => { - if (!taskId || !priorityId || !socket) return; - - const payload = { - task_id: taskId, - priority_id: priorityId, - team_id: currentSession?.team_id, - }; - - socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify(payload)); - socket.once( - SocketEvents.TASK_PRIORITY_CHANGE.toString(), - (data: ITaskListPriorityChangeResponse) => { - dispatch(updateBoardTaskPriority(data)); - } - ); - }; - - const handleDragEnd = async (event: DragEndEvent) => { - isDraggingRef.current = false; - const { active, over } = event; - - if (!over || !projectId) { - setActiveItem(null); - originalSourceGroupIdRef.current = null; - setClonedItems(null); - return; - } - - const isActiveTask = active.data.current?.type === 'task'; - const isActiveSection = active.data.current?.type === 'section'; - - // Handle task dragging between columns - if (isActiveTask) { - const task = active.data.current?.task; - - // Use the original source group ID from ref instead of the potentially modified one - const sourceGroupId = originalSourceGroupIdRef.current || active.data.current?.sectionId; - - // Fix: Ensure we correctly identify the target group ID - let targetGroupId; - if (over.data.current?.type === 'task') { - // If dropping on a task, get its section ID - targetGroupId = over.data.current?.sectionId; - } else if (over.data.current?.type === 'section') { - // If dropping directly on a section - targetGroupId = over.id; - } else { - // Fallback to the over ID if type is not specified - targetGroupId = over.id; - } - - // Find source and target groups - const sourceGroup = taskGroups.find(group => group.id === sourceGroupId); - const targetGroup = taskGroups.find(group => group.id === targetGroupId); - - if (!sourceGroup || !targetGroup || !task) { - logger.error('Could not find source or target group, or task is undefined'); - setActiveItem(null); - originalSourceGroupIdRef.current = null; // Reset the ref - return; - } - - if (targetGroupId !== sourceGroupId) { - const canContinue = await checkTaskDependencyStatus(task.id, targetGroupId); - if (!canContinue) { - alertService.error( - 'Task is not completed', - 'Please complete the task dependencies before proceeding' - ); - dispatch( - moveTaskBetweenGroups({ - taskId: task.id, - sourceGroupId: targetGroupId, // Current group (where it was moved optimistically) - targetGroupId: sourceGroupId, // Move it back to the original source group - targetIndex: currentTaskIndex !== -1 ? currentTaskIndex : 0, // Original position or append to end - }) - ); - - setActiveItem(null); - originalSourceGroupIdRef.current = null; - return; - } - } - - // Find indices - let fromIndex = sourceGroup.tasks.findIndex(t => t.id === task.id); - // Handle case where task is not found in source group (might have been moved already in UI) - if (fromIndex === -1) { - logger.info('Task not found in source group. Using task sort_order from task object.'); - - // Use the sort_order from the task object itself - const fromSortOrder = task.sort_order; - - // Calculate target index and position - let toIndex = -1; - if (over.data.current?.type === 'task') { - const overTaskId = over.data.current?.task.id; - toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId); - } else { - // If dropping on a section, append to the end - toIndex = targetGroup.tasks.length; - } - - // Calculate toPos similar to Angular implementation - const toPos = - targetGroup.tasks[toIndex]?.sort_order || - targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order || - -1; - - // Prepare socket event payload - const body = { - project_id: projectId, - from_index: fromSortOrder, - to_index: toPos, - to_last_index: !toPos, - from_group: sourceGroupId, - to_group: targetGroupId, - group_by: groupBy || 'status', - task, - team_id: currentSession?.team_id, - }; - - // Emit socket event - if (socket) { - socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body); - - // Set up listener for task progress update - socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => { - if (task.is_sub_task) { - socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id); - } else { - socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id); - } - }); - - // Handle priority change if groupBy is priority - if (groupBy === IGroupBy.PRIORITY) { - handlePriorityChange(task.id, targetGroupId); - } - } - - // Track analytics event - trackMixpanelEvent(evt_project_task_list_drag_and_move); - - setActiveItem(null); - originalSourceGroupIdRef.current = null; // Reset the ref - return; - } - - // Calculate target index and position - let toIndex = -1; - if (over.data.current?.type === 'task') { - const overTaskId = over.data.current?.task.id; - toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId); - } else { - // If dropping on a section, append to the end - toIndex = targetGroup.tasks.length; - } - - // Calculate toPos similar to Angular implementation - const toPos = - targetGroup.tasks[toIndex]?.sort_order || - targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order || - -1; - // Prepare socket event payload - const body = { - project_id: projectId, - from_index: sourceGroup.tasks[fromIndex].sort_order, - to_index: toPos, - to_last_index: !toPos, - from_group: sourceGroupId, // Use the direct IDs instead of group objects - to_group: targetGroupId, // Use the direct IDs instead of group objects - group_by: groupBy || 'status', // Use the current groupBy value - task, - team_id: currentSession?.team_id, - }; - // Emit socket event - if (socket) { - socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body); - - // Set up listener for task progress update - socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => { - if (task.is_sub_task) { - socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id); - } else { - socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id); - } - }); - } - // Track analytics event - trackMixpanelEvent(evt_project_task_list_drag_and_move); - } - // Handle column reordering - else if (isActiveSection) { - // Don't allow reordering if groupBy is phases - if (groupBy === IGroupBy.PHASE) { - setActiveItem(null); - originalSourceGroupIdRef.current = null; - return; - } - - const sectionId = active.id; - const fromIndex = taskGroups.findIndex(group => group.id === sectionId); - const toIndex = taskGroups.findIndex(group => group.id === over.id); - - if (fromIndex !== -1 && toIndex !== -1) { - // Create a new array with the reordered groups - const reorderedGroups = [...taskGroups]; - const [movedGroup] = reorderedGroups.splice(fromIndex, 1); - reorderedGroups.splice(toIndex, 0, movedGroup); - - // Dispatch action to reorder columns with the new array - dispatch(reorderTaskGroups(reorderedGroups)); - - // Prepare column order for API - const columnOrder = reorderedGroups.map(group => group.id); - - // Call API to update status order - try { - // Use the correct API endpoint based on the Angular code - const requestBody: ITaskStatusCreateRequest = { - status_order: columnOrder, - }; - - const response = await statusApiService.updateStatusOrder(requestBody, projectId); - if (!response.done) { - const revertedGroups = [...reorderedGroups]; - const [movedBackGroup] = revertedGroups.splice(toIndex, 1); - revertedGroups.splice(fromIndex, 0, movedBackGroup); - dispatch(reorderTaskGroups(revertedGroups)); - alertService.error('Failed to update column order', 'Please try again'); - } - } catch (error) { - // Revert the change if API call fails - const revertedGroups = [...reorderedGroups]; - const [movedBackGroup] = revertedGroups.splice(toIndex, 1); - revertedGroups.splice(fromIndex, 0, movedBackGroup); - dispatch(reorderTaskGroups(revertedGroups)); - alertService.error('Failed to update column order', 'Please try again'); - } - } - } - - setActiveItem(null); - originalSourceGroupIdRef.current = null; // Reset the ref - }; - - const handleDragCancel = () => { - isDraggingRef.current = false; - if (clonedItems) { - dispatch(reorderTaskGroups(clonedItems)); - } - setActiveItem(null); - setClonedItems(null); - originalSourceGroupIdRef.current = null; - }; - - // Reset the recently moved flag after animation frame - useEffect(() => { - requestAnimationFrame(() => { - recentlyMovedToNewContainer.current = false; - }); - }, [taskGroups]); - - useEffect(() => { - if (socket) { - socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress); - } - - return () => { - socket?.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress); - }; - }, [socket]); - - // Track analytics event on component mount - useEffect(() => { - trackMixpanelEvent(evt_project_board_visit); - }, []); - - // Cleanup debounced function on unmount - useEffect(() => { - return () => { - debouncedMoveTask.cancel(); - }; - }, [debouncedMoveTask]); - - return ( - - - - - - - {activeItem?.type === 'task' && ( - - )} - - - - - ); -}; - -export default ProjectViewBoard; diff --git a/worklenz-frontend/src/pages/projects/projectView/enhancedBoard/project-view-enhanced-board.tsx b/worklenz-frontend/src/pages/projects/projectView/enhancedBoard/project-view-enhanced-board.tsx index 39ec6c4c..62e6dd03 100644 --- a/worklenz-frontend/src/pages/projects/projectView/enhancedBoard/project-view-enhanced-board.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/enhancedBoard/project-view-enhanced-board.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { useAppSelector } from '@/hooks/useAppSelector'; -import EnhancedKanbanBoard from '@/components/enhanced-kanban/EnhancedKanbanBoard'; import EnhancedKanbanBoardNativeDnD from '@/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD'; const ProjectViewEnhancedBoard: React.FC = () => { @@ -12,7 +11,6 @@ const ProjectViewEnhancedBoard: React.FC = () => { return (
- {/* */}
); diff --git a/worklenz-frontend/src/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks.tsx b/worklenz-frontend/src/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks.tsx deleted file mode 100644 index 9091f19e..00000000 --- a/worklenz-frontend/src/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import TaskListBoard from '@/components/task-management/task-list-board'; - -const ProjectViewEnhancedTasks: React.FC = () => { - const { project } = useAppSelector(state => state.projectReducer); - - if (!project?.id) { - return
Project not found
; - } - - return ( -
- -
- ); -}; - -export default ProjectViewEnhancedTasks; diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx index 5c1a40e1..24a577c7 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx @@ -181,9 +181,24 @@ const ProjectViewHeader = memo(() => { // Memoized settings handler const handleSettingsClick = useCallback(() => { if (selectedProject?.id) { + console.log('Opening project drawer from project view for project:', selectedProject.id); + + // Set project ID first dispatch(setProjectId(selectedProject.id)); - dispatch(fetchProjectData(selectedProject.id)); - dispatch(toggleProjectDrawer()); + + // Then fetch project data + dispatch(fetchProjectData(selectedProject.id)) + .unwrap() + .then((projectData) => { + console.log('Project data fetched successfully from project view:', projectData); + // Open drawer after data is fetched + dispatch(toggleProjectDrawer()); + }) + .catch((error) => { + console.error('Failed to fetch project data from project view:', error); + // Still open drawer even if fetch fails, so user can see error state + dispatch(toggleProjectDrawer()); + }); } }, [dispatch, selectedProject?.id]); @@ -253,7 +268,7 @@ const ProjectViewHeader = memo(() => { { key: 'import', label: ( -
+
{t('importTask')}
), @@ -270,19 +285,21 @@ const ProjectViewHeader = memo(() => { if (selectedProject.category_id) { elements.push( - - {selectedProject.category_name} - + + + {selectedProject.category_name} + + ); } if (selectedProject.status) { elements.push( - + { if (selectedProject.start_date || selectedProject.end_date) { const tooltipContent = ( + {t('projectDatesInfo')} +
{selectedProject.start_date && `${t('startDate')}: ${formatDate(new Date(selectedProject.start_date))}`} {selectedProject.end_date && ( @@ -321,7 +340,7 @@ const ProjectViewHeader = memo(() => { } return ( - + {elements} ); @@ -333,7 +352,7 @@ const ProjectViewHeader = memo(() => { // Refresh button actions.push( - + + + + ); } // Create task button if (isOwnerOrAdmin) { actions.push( - } - menu={{ items: dropdownItems }} - trigger={['click']} - onClick={handleCreateTask} - > - {t('createTask')} - + + } + menu={{ items: dropdownItems }} + trigger={['click']} + onClick={handleCreateTask} + > + {t('createTask')} + + ); } else { actions.push( - + + + ); } return ( - + {actions} ); @@ -435,22 +460,23 @@ const ProjectViewHeader = memo(() => { // Memoized page header title const pageHeaderTitle = useMemo( () => ( - - - + + + + + {selectedProject?.name} {projectAttributes} ), - [handleNavigateToProjects, selectedProject?.name, projectAttributes] + [handleNavigateToProjects, selectedProject?.name, projectAttributes, t] ); // Memoized page header styles const pageHeaderStyle = useMemo( () => ({ paddingInline: 0, - marginBlockEnd: 12, }), [] ); diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index 32b53f08..7509d74b 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -32,7 +32,7 @@ import { resetSelection } from '@/features/task-management/selection.slice'; import { resetFields } from '@/features/task-management/taskListFields.slice'; import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice'; import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; -import { tabItems } from '@/lib/project/project-view-constants'; +import { tabItems, updateTabLabels } from '@/lib/project/project-view-constants'; import { setSelectedTaskId, setShowTaskDrawer, @@ -41,6 +41,7 @@ import { import { resetState as resetEnhancedKanbanState } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import { setProjectId as setInsightsProjectId } from '@/features/projects/insights/project-insights.slice'; import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; +import { useTranslation } from 'react-i18next'; // Import critical components synchronously to avoid suspense interruptions import TaskDrawer from '@components/task-drawer/task-drawer'; @@ -63,13 +64,14 @@ const ProjectView = React.memo(() => { const dispatch = useAppDispatch(); const [searchParams] = useSearchParams(); const { projectId } = useParams(); + const { t } = useTranslation('project-view'); // Memoized selectors to prevent unnecessary re-renders const selectedProject = useAppSelector(state => state.projectReducer.project); const projectLoading = useAppSelector(state => state.projectReducer.projectLoading); // Optimize document title updates - useDocumentTitle(selectedProject?.name || 'Project View'); + useDocumentTitle(selectedProject?.name || t('projectView')); // Memoize URL params to prevent unnecessary state updates const urlParams = useMemo( @@ -174,6 +176,11 @@ const ProjectView = React.memo(() => { setIsInitialized(false); }, [projectId]); + // Update tab labels when language changes + useEffect(() => { + updateTabLabels(); + }, [t]); + // Effect for handling task drawer opening from URL params useEffect(() => { if (taskid && isInitialized) { @@ -287,6 +294,7 @@ const ProjectView = React.memo(() => { e.stopPropagation(); pinToDefaultTab(item.key); }} + title={item.key === pinnedTab ? t('unpinTab') : t('pinTab')} /> )} @@ -296,7 +304,7 @@ const ProjectView = React.memo(() => { })); return menuItems; - }, [pinnedTab, pinToDefaultTab]); + }, [pinnedTab, pinToDefaultTab, t]); // Optimized secondary components loading with better UX const [shouldLoadSecondaryComponents, setShouldLoadSecondaryComponents] = useState(false); @@ -336,14 +344,14 @@ const ProjectView = React.memo(() => { // Show loading state while project is being fetched if (projectLoading || !isInitialized) { return ( -
+
); } return ( -
+
{ - const dispatch = useAppDispatch(); - const { projectView } = useTabSearchParam(); - const [searchParams, setSearchParams] = useSearchParams(); - const [coreDataLoaded, setCoreDataLoaded] = useState(false); - - // Split selectors to prevent unnecessary rerenders - const projectId = useAppSelector(state => state.projectReducer.projectId); - const taskGroups = useAppSelector(state => state.taskReducer.taskGroups); - const loadingGroups = useAppSelector(state => state.taskReducer.loadingGroups); - const groupBy = useAppSelector(state => state.taskReducer.groupBy); - const archived = useAppSelector(state => state.taskReducer.archived); - const fields = useAppSelector(state => state.taskReducer.fields); - const search = useAppSelector(state => state.taskReducer.search); - - const statusCategories = useAppSelector(state => state.taskStatusReducer.statusCategories); - const loadingStatusCategories = useAppSelector(state => state.taskStatusReducer.loading); - - const loadingPhases = useAppSelector(state => state.phaseReducer.loadingPhases); - - // Simplified loading state - only wait for essential data - // Remove dependency on phases and status categories for initial render - const isLoading = useMemo( - () => loadingGroups || !coreDataLoaded, - [loadingGroups, coreDataLoaded] - ); - - // Memoize the empty state check - const isEmptyState = useMemo( - () => taskGroups && taskGroups.length === 0 && !isLoading, - [taskGroups, isLoading] - ); - - // Handle view type changes - useEffect(() => { - if (projectView !== 'list' && projectView !== 'board') { - const newParams = new URLSearchParams(searchParams); - newParams.set('tab', 'tasks-list'); - newParams.set('pinned_tab', 'tasks-list'); - setSearchParams(newParams); - } - }, [projectView, setSearchParams, searchParams]); - - // Optimized parallel data fetching - don't wait for everything - useEffect(() => { - const fetchCoreData = async () => { - if (!projectId || !groupBy || coreDataLoaded) return; - - try { - // Start all requests in parallel, but only wait for task columns - // Other data can load in background without blocking UI - const corePromises = [ - dispatch(fetchTaskListColumns(projectId)), - dispatch(fetchTaskGroups(projectId)), // Start immediately - ]; - - // Background data - don't wait for these - dispatch(fetchPhasesByProjectId(projectId)); - dispatch(fetchStatusesCategories()); - - // Only wait for essential data - await Promise.allSettled(corePromises); - setCoreDataLoaded(true); - } catch (error) { - console.error('Error fetching core data:', error); - setCoreDataLoaded(true); // Still mark as complete to prevent infinite loading - } - }; - - fetchCoreData(); - }, [projectId, groupBy, dispatch, coreDataLoaded]); - - // Optimized task groups fetching - remove initialLoadComplete dependency - useEffect(() => { - const fetchTasks = async () => { - if (!projectId || !groupBy || projectView !== 'list') return; - - try { - // Only refetch if filters change, not on initial load - if (coreDataLoaded) { - await dispatch(fetchTaskGroups(projectId)); - } - } catch (error) { - console.error('Error fetching task groups:', error); - } - }; - - // Only refetch when filters change - if (coreDataLoaded) { - fetchTasks(); - } - }, [projectId, groupBy, projectView, dispatch, fields, search, archived, coreDataLoaded]); - - // Memoize the task groups to prevent unnecessary re-renders - const memoizedTaskGroups = useMemo(() => taskGroups || [], [taskGroups]); - - return ( - - {/* Filters load synchronously - no suspense boundary */} - - - {isEmptyState ? ( - - ) : ( - - - - )} - - ); -}; - -export default ProjectViewTaskList; diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx deleted file mode 100644 index 83c59535..00000000 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, { useEffect, useMemo } from 'react'; -import { createPortal } from 'react-dom'; -import Flex from 'antd/es/flex'; -import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect'; - -import { ITaskListGroup } from '@/types/tasks/taskList.types'; -import { useAppSelector } from '@/hooks/useAppSelector'; - -import TaskListTableWrapper from './task-list-table/task-list-table-wrapper/task-list-table-wrapper'; - -import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer'; - -import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; - -interface TaskGroupWrapperOptimizedProps { - taskGroups: ITaskListGroup[]; - groupBy: string; -} - -const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOptimizedProps) => { - const themeMode = useAppSelector((state: any) => state.themeReducer.mode); - - // Use extracted hooks - useTaskSocketHandlers(); - - // Memoize task groups with colors - const taskGroupsWithColors = useMemo( - () => - taskGroups?.map(taskGroup => ({ - ...taskGroup, - displayColor: themeMode === 'dark' ? taskGroup.color_code_dark : taskGroup.color_code, - })) || [], - [taskGroups, themeMode] - ); - - // Add drag styles without animations - useEffect(() => { - const style = document.createElement('style'); - style.textContent = ` - .task-row[data-is-dragging="true"] { - opacity: 0.5 !important; - z-index: 1000 !important; - position: relative !important; - } - .task-row { - /* Remove transitions during drag operations */ - } - `; - document.head.appendChild(style); - - return () => { - document.head.removeChild(style); - }; - }, []); - - // Remove the animation cleanup since we're simplifying the approach - - return ( - - {taskGroupsWithColors.map(taskGroup => ( - - ))} - - {createPortal( - {}} />, - document.body, - 'task-template-drawer' - )} - - ); -}; - -export default React.memo(TaskGroupWrapperOptimized); diff --git a/worklenz-frontend/src/pages/reporting/time-reports/project-time-sheet/project-time-sheet-chart.tsx b/worklenz-frontend/src/pages/reporting/time-reports/project-time-sheet/project-time-sheet-chart.tsx index 6f912f83..6881c702 100644 --- a/worklenz-frontend/src/pages/reporting/time-reports/project-time-sheet/project-time-sheet-chart.tsx +++ b/worklenz-frontend/src/pages/reporting/time-reports/project-time-sheet/project-time-sheet-chart.tsx @@ -1,5 +1,4 @@ -import React, { useEffect, useState, forwardRef, useImperativeHandle } from 'react'; -import { Bar } from 'react-chartjs-2'; +import React, { useEffect, useState, forwardRef, useImperativeHandle, lazy, Suspense } from 'react'; import { Chart as ChartJS, CategoryScale, @@ -20,7 +19,34 @@ import { IRPTTimeProject } from '@/types/reporting/reporting.types'; import { Empty, Spin } from 'antd'; import logger from '@/utils/errorLogger'; -ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels); +// Lazy load the Bar chart component +const LazyBarChart = lazy(() => + import('react-chartjs-2').then(module => ({ default: module.Bar })) +); + +// Chart loading fallback +const ChartLoadingFallback = () => ( +
+ +
+); + +// Register Chart.js components only when needed +let isChartJSRegistered = false; +const registerChartJS = () => { + if (!isChartJSRegistered) { + ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels); + isChartJSRegistered = true; + } +}; const BAR_THICKNESS = 40; const STROKE_WIDTH = 4; @@ -36,6 +62,7 @@ const ProjectTimeSheetChart = forwardRef((_, ref) => { const { t } = useTranslation('time-report'); const [jsonData, setJsonData] = useState([]); const [loading, setLoading] = useState(false); + const [chartReady, setChartReady] = useState(false); const chartRef = React.useRef>(null); const themeMode = useAppSelector(state => state.themeReducer.mode); @@ -51,6 +78,21 @@ const ProjectTimeSheetChart = forwardRef((_, ref) => { } = useAppSelector(state => state.timeReportsOverviewReducer); const { duration, dateRange } = useAppSelector(state => state.reportingReducer); + // Initialize chart when component mounts + useEffect(() => { + const initChart = () => { + registerChartJS(); + setChartReady(true); + }; + + // Use requestIdleCallback to defer chart initialization + if ('requestIdleCallback' in window) { + requestIdleCallback(initChart, { timeout: 1000 }); + } else { + setTimeout(initChart, 500); + } + }, []); + const handleBarClick = (event: any, elements: any) => { if (elements.length > 0) { const elementIndex = elements[0].index; @@ -158,7 +200,7 @@ const ProjectTimeSheetChart = forwardRef((_, ref) => { }; useEffect(() => { - if (!loadingTeams && !loadingProjects && !loadingCategories) { + if (!loadingTeams && !loadingProjects && !loadingCategories && chartReady) { setLoading(true); fetchChartData().finally(() => { setLoading(false); @@ -175,6 +217,7 @@ const ProjectTimeSheetChart = forwardRef((_, ref) => { loadingTeams, loadingProjects, loadingCategories, + chartReady, ]); const exportChart = () => { @@ -200,8 +243,8 @@ const ProjectTimeSheetChart = forwardRef((_, ref) => { // Create download link const link = document.createElement('a'); - link.download = 'project-time-sheet.png'; - link.href = tempCanvas.toDataURL('image/png'); + link.download = 'project-time-sheet-chart.png'; + link.href = tempCanvas.toDataURL(); link.click(); } }; @@ -210,25 +253,35 @@ const ProjectTimeSheetChart = forwardRef((_, ref) => { exportChart, })); - // if (loading) { - // return ( - //
- // - //
- // ); - // } + if (loading) { + return ( +
+ +
+ ); + } + + if (!Array.isArray(jsonData) || jsonData.length === 0) { + return ( +
+ +
+ ); + } + + const chartHeight = jsonData.length * (BAR_THICKNESS + 10) + 100; + const containerHeight = Math.max(chartHeight, 400); return ( -
-
- +
+
+ {chartReady ? ( + }> + + + ) : ( + + )}
diff --git a/worklenz-frontend/src/pages/settings/language-and-region/language-and-region-settings.tsx b/worklenz-frontend/src/pages/settings/language-and-region/language-and-region-settings.tsx index 42658634..4dc0191c 100644 --- a/worklenz-frontend/src/pages/settings/language-and-region/language-and-region-settings.tsx +++ b/worklenz-frontend/src/pages/settings/language-and-region/language-and-region-settings.tsx @@ -55,6 +55,10 @@ const LanguageAndRegionSettings = () => { value: Language.DE, label: 'Deutsch', }, + { + value: Language.ZH_CN, + label: '简体中文', + }, ]; const handleLanguageChange = async (values: { language?: ILanguageType; timezone?: string }) => { diff --git a/worklenz-frontend/src/pages/settings/profile/profile-settings.tsx b/worklenz-frontend/src/pages/settings/profile/profile-settings.tsx index b62e91c2..cd8950e1 100644 --- a/worklenz-frontend/src/pages/settings/profile/profile-settings.tsx +++ b/worklenz-frontend/src/pages/settings/profile/profile-settings.tsx @@ -4,11 +4,9 @@ import { Card, Flex, Form, - GetProp, Input, Tooltip, Typography, - UploadProps, Spin, Skeleton, } from 'antd'; @@ -20,7 +18,6 @@ import { useDocumentTitle } from '@/hooks/useDoumentTItle'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import { evt_settings_profile_visit, - evt_settings_profile_avatar_upload, evt_settings_profile_name_change, evt_settings_profile_picture_update, } from '@/shared/worklenz-analytics-events'; diff --git a/worklenz-frontend/src/pages/settings/sidebar/settings-sidebar.tsx b/worklenz-frontend/src/pages/settings/sidebar/settings-sidebar.tsx index b233018a..61f67a94 100644 --- a/worklenz-frontend/src/pages/settings/sidebar/settings-sidebar.tsx +++ b/worklenz-frontend/src/pages/settings/sidebar/settings-sidebar.tsx @@ -24,7 +24,7 @@ const SettingSidebar: React.FC = () => { const items: Required['items'] = accessibleSettings .map(item => { if (currentSession?.is_google && item.key === 'change-password') { - return undefined; + return null; } return { key: item.key, @@ -39,7 +39,7 @@ const SettingSidebar: React.FC = () => { ), }; }) - .filter(Boolean); + .filter((item): item is NonNullable => item !== null); return ( { const { socket } = useSocket(); const refreshTeamMembers = useAppSelector(state => state.memberReducer.refreshTeamMembers); // Listen to refresh flag + useDocumentTitle(t('title') || 'Team Members'); + const [model, setModel] = useState({ total: 0, data: [] }); const [searchQuery, setSearchQuery] = useState(''); const [isLoading, setIsLoading] = useState(false); diff --git a/worklenz-frontend/src/pages/settings/teams/teams-settings.tsx b/worklenz-frontend/src/pages/settings/teams/teams-settings.tsx index a46a10b4..5c21fb0c 100644 --- a/worklenz-frontend/src/pages/settings/teams/teams-settings.tsx +++ b/worklenz-frontend/src/pages/settings/teams/teams-settings.tsx @@ -5,6 +5,7 @@ import { durationDateFormat } from '@utils/durationDateFormat'; import { EditOutlined } from '@ant-design/icons'; import { useEffect, useState } from 'react'; import EditTeamModal from '@/components/settings/edit-team-name-modal'; +import { useTranslation } from 'react-i18next'; import { fetchTeams } from '@features/teams/teamSlice'; import { useAppDispatch } from '@/hooks/useAppDispatch'; @@ -12,7 +13,8 @@ import { useDocumentTitle } from '@/hooks/useDoumentTItle'; import { ITeamGetResponse } from '@/types/teams/team.type'; const TeamsSettings = () => { - useDocumentTitle('Teams'); + const { t } = useTranslation('settings/teams'); + useDocumentTitle(t('title')); const [selectedTeam, setSelectedTeam] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); @@ -26,27 +28,27 @@ const TeamsSettings = () => { const columns: TableProps['columns'] = [ { key: 'name', - title: 'Name', + title: t('name'), render: (record: ITeamGetResponse) => {record.name}, }, { key: 'created', - title: 'Created', + title: t('created'), render: (record: ITeamGetResponse) => ( {durationDateFormat(record.created_at)} ), }, { key: 'ownsBy', - title: 'Owns By', + title: t('ownsBy'), render: (record: ITeamGetResponse) => {record.owns_by}, }, { key: 'actionBtns', width: 60, render: (record: ITeamGetResponse) => ( - +