From 11694de4e6958e81bc160268edc07fef3d7d3f1f Mon Sep 17 00:00:00 2001 From: kithmina1999 Date: Fri, 6 Jun 2025 12:38:46 +0530 Subject: [PATCH 01/39] feat(db): add database backup and initialization system - Add backup.sh script for manual PostgreSQL database backups - Update .gitignore to exclude pg_backups directory - Modify docker-compose.yml to include backup service and volume mounts - Add init.sh script for automated database initialization with backup restoration --- .gitignore | 137 +++++++++++++++--------------- backup.sh | 16 ++++ docker-compose.yml | 46 +++++++++- worklenz-backend/database/init.sh | 88 +++++++++++++++++++ 4 files changed, 216 insertions(+), 71 deletions(-) create mode 100644 backup.sh create mode 100644 worklenz-backend/database/init.sh diff --git a/.gitignore b/.gitignore index d255be7f..f17ba915 100644 --- a/.gitignore +++ b/.gitignore @@ -1,79 +1,82 @@ -# Dependencies -node_modules/ -.pnp/ -.pnp.js + # Dependencies + node_modules/ + .pnp/ + .pnp.js -# Build outputs -dist/ -build/ -out/ -.next/ -.nuxt/ -.cache/ + # Build outputs + dist/ + build/ + out/ + .next/ + .nuxt/ + .cache/ -# Environment variables -.env -.env.local -.env.development.local -.env.test.local -.env.production.local -.env.development -.env.production -.env.* -!.env.example -!.env.template + # Environment variables + .env + .env.local + .env.development.local + .env.test.local + .env.production.local + .env.development + .env.production + .env.* + !.env.example + !.env.template -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* + # Logs + logs + *.log + npm-debug.log* + yarn-debug.log* + yarn-error.log* + pnpm-debug.log* + lerna-debug.log* -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea/ -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? -*.sublime-workspace + #backups + pg_backups/ -# Testing -coverage/ -.nyc_output/ + # Editor directories and files + .vscode/* + !.vscode/extensions.json + .idea/ + .DS_Store + *.suo + *.ntvs* + *.njsproj + *.sln + *.sw? + *.sublime-workspace -# Temp files -.temp/ -.tmp/ -temp/ -tmp/ + # Testing + coverage/ + .nyc_output/ -# Debug -.debug/ + # Temp files + .temp/ + .tmp/ + temp/ + tmp/ -# Misc -.DS_Store -Thumbs.db -.thumbs.db -ehthumbs.db -Desktop.ini -$RECYCLE.BIN/ + # Debug + .debug/ -# Yarn -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions + # Misc + .DS_Store + Thumbs.db + .thumbs.db + ehthumbs.db + Desktop.ini + $RECYCLE.BIN/ -# TypeScript -*.tsbuildinfo + # Yarn + .yarn/* + !.yarn/patches + !.yarn/plugins + !.yarn/releases + !.yarn/sdks + !.yarn/versions + + # TypeScript + *.tsbuildinfo 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 e7d074ad..8c539ae1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,7 +80,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 @@ -89,9 +93,20 @@ 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/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 @@ -101,11 +116,34 @@ services: dos2unix "{}" 2>/dev/null || true chmod +x "{}" '\'' \; && 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/init.sh b/worklenz-backend/database/init.sh new file mode 100644 index 00000000..afd8562a --- /dev/null +++ b/worklenz-backend/database/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." From dc22d1e6cbd254471ae44e4f69dc602a22e3a556 Mon Sep 17 00:00:00 2001 From: kithmina1999 Date: Fri, 6 Jun 2025 15:12:06 +0530 Subject: [PATCH 02/39] fix(db): improve database initialization script and docker setup - Rename init.sh to 00_init.sh for better ordering - Format docker-compose command for better readability - Add comprehensive database initialization script with backup restoration - Implement proper migration handling with schema_migrations table --- docker-compose.yml | 23 +++++++++++-------- .../database/{init.sh => 00_init.sh} | 0 2 files changed, 14 insertions(+), 9 deletions(-) rename worklenz-backend/database/{init.sh => 00_init.sh} (100%) diff --git a/docker-compose.yml b/docker-compose.yml index 8c539ae1..ac6987ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -101,21 +101,26 @@ services: target: /docker-entrypoint-initdb.d/migrations consistency: cached - type: bind - source: ./worklenz-backend/database/init.sh + 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 '\'' + dos2unix "{}" 2>/dev/null || true + chmod +x "{}" + '\'' \; + exec docker-entrypoint.sh postgres + ' + db-backup: image: postgres:15 container_name: worklenz_db_backup diff --git a/worklenz-backend/database/init.sh b/worklenz-backend/database/00_init.sh similarity index 100% rename from worklenz-backend/database/init.sh rename to worklenz-backend/database/00_init.sh From 0987fb14b263b29b5ceb1b7f4a8c6400c53f6353 Mon Sep 17 00:00:00 2001 From: kithmina1999 Date: Fri, 6 Jun 2025 15:34:18 +0530 Subject: [PATCH 03/39] refactor(docker): improve command formatting and fix shell script issues - Fix shell script syntax in db service command by using proper quoting and loop structure - Clean up indentation and formatting in both db and db-backup services - Ensure consistent command structure while maintaining the same functionality --- docker-compose.yml | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ac6987ae..fe936e16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -107,19 +107,24 @@ services: - 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 - ' + 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 ' + for file; do + dos2unix \"$file\" 2>/dev/null || true + chmod +x \"$file\" + done + ' sh {} + + + exec docker-entrypoint.sh postgres + " + db-backup: image: postgres:15 @@ -135,12 +140,12 @@ services: - ./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" + 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 From 1442c57e18b721849e7777f8bad80a1c9221988c Mon Sep 17 00:00:00 2001 From: kithmina1999 Date: Fri, 6 Jun 2025 16:06:42 +0530 Subject: [PATCH 04/39] refactor(docker): improve postgres container initialization script The command for the postgres service was restructured to: 1. Use more readable multi-line formatting 2. Replace the find -exec with a more efficient for loop 3. Maintain the same functionality while improving maintainability --- docker-compose.yml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 8c539ae1..dbf96f7d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -108,14 +108,20 @@ services: 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 From c5e480af52a4700e0df0c0899b5df5c83553b77d Mon Sep 17 00:00:00 2001 From: kithmina1999 Date: Fri, 6 Jun 2025 16:30:46 +0530 Subject: [PATCH 05/39] fix(db): correct variable syntax in pg_dump command The previous version used $$ for variable expansion which doesn't work in this context. Changed to single $ for proper shell variable expansion in the database backup command. --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 16d33195..64277545 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -141,7 +141,7 @@ services: 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; + > /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 From f142046dccca16972309f5548f3c438a2a835131 Mon Sep 17 00:00:00 2001 From: kithmina1999 Date: Fri, 6 Jun 2025 16:32:11 +0530 Subject: [PATCH 06/39] fix(docker): correct bash syntax in postgres backup command The command was using incorrect quote escaping which could cause issues with variable expansion and file deletion. Fixed by using single quotes for the outer string and proper escaping for date command substitution. --- docker-compose.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 64277545..71d74475 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -138,12 +138,12 @@ services: - ./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" + 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 From 81a6c44090f9e846564a8458d36da6ed56ed182e Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 6 Jun 2025 17:03:31 +0530 Subject: [PATCH 07/39] refactor(info-tab-footer): enhance member selection and comment handling - Updated member selection to use member names as values for better display. - Improved handling of selected members to ensure correct identification using member IDs. - Reset selected members and comment value upon comment submission. - Enhanced comment input with filtering options for mentions, improving user experience. --- .../shared/info-tab/info-tab-footer.tsx | 56 ++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) 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 2e79c42c..b98c09fe 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 @@ -71,6 +71,8 @@ const InfoTabFooter = () => { setIsCommentBoxExpand(false); setSelectedFiles([]); setAttachmentComment(false); + setCommentValue(''); + setSelectedMembers([]); }; // Check if comment is valid (either has text or files) @@ -97,30 +99,27 @@ const InfoTabFooter = () => { // mentions options const mentionsOptions = members?.map(member => ({ - value: member.id, + value: member.name, // Use name as value so it displays correctly label: member.name, + key: member.id, // Keep ID as key for identification })) ?? []; const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => { - console.log('member', member); if (!member?.value || !member?.label) return; + + // Find the member ID from the members array using the name + const selectedMember = members.find(m => m.name === member.value); + if (!selectedMember || !selectedMember.id || !selectedMember.name) return; + + // Add to selected members if not already present setSelectedMembers(prev => - prev.some(mention => mention.team_member_id === member.value) + prev.some(mention => mention.team_member_id === selectedMember.id) ? prev - : [...prev, { team_member_id: member.value, name: member.label }] + : [...prev, { team_member_id: selectedMember.id!, name: selectedMember.name! }] ); - - setCommentValue(prev => { - const parts = prev.split('@'); - const lastPart = parts[parts.length - 1]; - const mentionText = member.label; - // Keep only the part before the @ and add the new mention - return prev.slice(0, prev.length - lastPart.length) + mentionText; - }); - }, []); + }, [members]); const handleCommentChange = useCallback((value: string) => { - // Only update the value without trying to replace mentions setCommentValue(value); setCharacterLength(value.trim().length); }, []); @@ -152,6 +151,7 @@ const InfoTabFooter = () => { setAttachmentComment(false); setIsCommentBoxExpand(false); setCommentValue(''); + setSelectedMembers([]); // Dispatch event to notify that a comment was created // This will trigger scrolling to the new comment @@ -275,6 +275,12 @@ const InfoTabFooter = () => { maxLength={5000} onClick={() => setIsCommentBoxExpand(true)} onChange={e => setCharacterLength(e.length)} + prefix="@" + filterOption={(input, option) => { + if (!input) return true; + const optionLabel = (option as any)?.label || ''; + return optionLabel.toLowerCase().includes(input.toLowerCase()); + }} style={{ minHeight: 60, resize: 'none', @@ -371,7 +377,11 @@ const InfoTabFooter = () => { onSelect={option => memberSelectHandler(option as IMentionMemberSelectOption)} onChange={handleCommentChange} prefix="@" - split="" + filterOption={(input, option) => { + if (!input) return true; + const optionLabel = (option as any)?.label || ''; + return optionLabel.toLowerCase().includes(input.toLowerCase()); + }} style={{ minHeight: 100, maxHeight: 200, @@ -437,31 +447,27 @@ const InfoTabFooter = () => { Created{' '} - {taskFormViewModel?.task?.created_at - ? calculateTimeDifference(taskFormViewModel.task.created_at) - : 'N/A'}{' '} + {taskFormViewModel?.task?.created_from_now || 'N/A'}{' '} by {taskFormViewModel?.task?.reporter} Updated{' '} - {taskFormViewModel?.task?.updated_at - ? calculateTimeDifference(taskFormViewModel.task.updated_at) - : 'N/A'} + {taskFormViewModel?.task?.updated_from_now || 'N/A'} From 39e09bedd3f45eabefc1e092611bcb1fd8fc8e29 Mon Sep 17 00:00:00 2001 From: jiuhao47 Date: Mon, 30 Jun 2025 20:52:49 +0800 Subject: [PATCH 08/39] add support for zh_cn --- worklenz-backend/database/sql/1_tables.sql | 2 +- .../public/locales/zh/404-page.json | 4 + .../public/locales/zh/account-setup.json | 27 ++++++ .../locales/zh/admin-center/current-bill.json | 96 +++++++++++++++++++ .../locales/zh/admin-center/overview.json | 8 ++ .../locales/zh/admin-center/projects.json | 12 +++ .../locales/zh/admin-center/sidebar.json | 8 ++ .../public/locales/zh/admin-center/teams.json | 33 +++++++ .../public/locales/zh/admin-center/users.json | 9 ++ .../public/locales/zh/all-project-list.json | 23 +++++ .../public/locales/zh/auth/auth-common.json | 5 + .../locales/zh/auth/forgot-password.json | 12 +++ .../public/locales/zh/auth/login.json | 27 ++++++ .../public/locales/zh/auth/signup.json | 29 ++++++ .../locales/zh/auth/verify-reset-email.json | 14 +++ .../public/locales/zh/common.json | 9 ++ .../locales/zh/create-first-project-form.json | 13 +++ .../public/locales/zh/create-first-tasks.json | 7 ++ worklenz-frontend/public/locales/zh/home.json | 46 +++++++++ .../zh/invite-initial-team-members.json | 8 ++ .../public/locales/zh/kanban-board.json | 19 ++++ .../public/locales/zh/license-expired.json | 6 ++ .../public/locales/zh/navbar.json | 31 ++++++ .../locales/zh/organization-name-form.json | 5 + .../public/locales/zh/phases-drawer.json | 7 ++ .../public/locales/zh/project-drawer.json | 42 ++++++++ .../public/locales/zh/project-view-files.json | 14 +++ .../locales/zh/project-view-insights.json | 41 ++++++++ .../locales/zh/project-view-members.json | 17 ++++ .../locales/zh/project-view-updates.json | 6 ++ .../project-view/import-task-templates.json | 11 +++ .../project-view/project-member-drawer.json | 7 ++ .../zh/project-view/project-view-header.json | 13 +++ .../zh/project-view/save-as-template.json | 27 ++++++ .../locales/zh/reporting-members-drawer.json | 76 +++++++++++++++ .../public/locales/zh/reporting-members.json | 31 ++++++ .../locales/zh/reporting-overview-drawer.json | 33 +++++++ .../public/locales/zh/reporting-overview.json | 22 +++++ .../locales/zh/reporting-projects-drawer.json | 52 ++++++++++ .../zh/reporting-projects-filters.json | 31 ++++++ .../public/locales/zh/reporting-projects.json | 44 +++++++++ .../public/locales/zh/reporting-sidebar.json | 8 ++ .../public/locales/zh/schedule.json | 34 +++++++ .../locales/zh/settings/categories.json | 10 ++ .../locales/zh/settings/change-password.json | 15 +++ .../public/locales/zh/settings/clients.json | 22 +++++ .../locales/zh/settings/job-titles.json | 20 ++++ .../public/locales/zh/settings/labels.json | 11 +++ .../public/locales/zh/settings/language.json | 7 ++ .../locales/zh/settings/notifications.json | 11 +++ .../public/locales/zh/settings/profile.json | 13 +++ .../zh/settings/project-templates.json | 8 ++ .../public/locales/zh/settings/sidebar.json | 14 +++ .../locales/zh/settings/task-templates.json | 9 ++ .../locales/zh/settings/team-members.json | 44 +++++++++ .../zh/task-drawer/task-drawer-info-tab.json | 29 ++++++ .../locales/zh/task-drawer/task-drawer.json | 78 +++++++++++++++ .../public/locales/zh/task-list-filters.json | 54 +++++++++++ .../public/locales/zh/task-list-table.json | 56 +++++++++++ .../locales/zh/task-template-drawer.json | 11 +++ .../zh/tasks/task-table-bulk-actions.json | 24 +++++ .../public/locales/zh/template-drawer.json | 19 ++++ .../public/locales/zh/templateDrawer.json | 23 +++++ .../public/locales/zh/time-report.json | 33 +++++++ .../public/locales/zh/unauthorized.json | 5 + .../src/features/i18n/language-selector.tsx | 2 + .../src/features/i18n/localesSlice.ts | 1 + .../language-and-region-settings.tsx | 6 +- worklenz-frontend/src/utils/greetingString.ts | 6 ++ 69 files changed, 1498 insertions(+), 2 deletions(-) create mode 100644 worklenz-frontend/public/locales/zh/404-page.json create mode 100644 worklenz-frontend/public/locales/zh/account-setup.json create mode 100644 worklenz-frontend/public/locales/zh/admin-center/current-bill.json create mode 100644 worklenz-frontend/public/locales/zh/admin-center/overview.json create mode 100644 worklenz-frontend/public/locales/zh/admin-center/projects.json create mode 100644 worklenz-frontend/public/locales/zh/admin-center/sidebar.json create mode 100644 worklenz-frontend/public/locales/zh/admin-center/teams.json create mode 100644 worklenz-frontend/public/locales/zh/admin-center/users.json create mode 100644 worklenz-frontend/public/locales/zh/all-project-list.json create mode 100644 worklenz-frontend/public/locales/zh/auth/auth-common.json create mode 100644 worklenz-frontend/public/locales/zh/auth/forgot-password.json create mode 100644 worklenz-frontend/public/locales/zh/auth/login.json create mode 100644 worklenz-frontend/public/locales/zh/auth/signup.json create mode 100644 worklenz-frontend/public/locales/zh/auth/verify-reset-email.json create mode 100644 worklenz-frontend/public/locales/zh/common.json create mode 100644 worklenz-frontend/public/locales/zh/create-first-project-form.json create mode 100644 worklenz-frontend/public/locales/zh/create-first-tasks.json create mode 100644 worklenz-frontend/public/locales/zh/home.json create mode 100644 worklenz-frontend/public/locales/zh/invite-initial-team-members.json create mode 100644 worklenz-frontend/public/locales/zh/kanban-board.json create mode 100644 worklenz-frontend/public/locales/zh/license-expired.json create mode 100644 worklenz-frontend/public/locales/zh/navbar.json create mode 100644 worklenz-frontend/public/locales/zh/organization-name-form.json create mode 100644 worklenz-frontend/public/locales/zh/phases-drawer.json create mode 100644 worklenz-frontend/public/locales/zh/project-drawer.json create mode 100644 worklenz-frontend/public/locales/zh/project-view-files.json create mode 100644 worklenz-frontend/public/locales/zh/project-view-insights.json create mode 100644 worklenz-frontend/public/locales/zh/project-view-members.json create mode 100644 worklenz-frontend/public/locales/zh/project-view-updates.json create mode 100644 worklenz-frontend/public/locales/zh/project-view/import-task-templates.json create mode 100644 worklenz-frontend/public/locales/zh/project-view/project-member-drawer.json create mode 100644 worklenz-frontend/public/locales/zh/project-view/project-view-header.json create mode 100644 worklenz-frontend/public/locales/zh/project-view/save-as-template.json create mode 100644 worklenz-frontend/public/locales/zh/reporting-members-drawer.json create mode 100644 worklenz-frontend/public/locales/zh/reporting-members.json create mode 100644 worklenz-frontend/public/locales/zh/reporting-overview-drawer.json create mode 100644 worklenz-frontend/public/locales/zh/reporting-overview.json create mode 100644 worklenz-frontend/public/locales/zh/reporting-projects-drawer.json create mode 100644 worklenz-frontend/public/locales/zh/reporting-projects-filters.json create mode 100644 worklenz-frontend/public/locales/zh/reporting-projects.json create mode 100644 worklenz-frontend/public/locales/zh/reporting-sidebar.json create mode 100644 worklenz-frontend/public/locales/zh/schedule.json create mode 100644 worklenz-frontend/public/locales/zh/settings/categories.json create mode 100644 worklenz-frontend/public/locales/zh/settings/change-password.json create mode 100644 worklenz-frontend/public/locales/zh/settings/clients.json create mode 100644 worklenz-frontend/public/locales/zh/settings/job-titles.json create mode 100644 worklenz-frontend/public/locales/zh/settings/labels.json create mode 100644 worklenz-frontend/public/locales/zh/settings/language.json create mode 100644 worklenz-frontend/public/locales/zh/settings/notifications.json create mode 100644 worklenz-frontend/public/locales/zh/settings/profile.json create mode 100644 worklenz-frontend/public/locales/zh/settings/project-templates.json create mode 100644 worklenz-frontend/public/locales/zh/settings/sidebar.json create mode 100644 worklenz-frontend/public/locales/zh/settings/task-templates.json create mode 100644 worklenz-frontend/public/locales/zh/settings/team-members.json create mode 100644 worklenz-frontend/public/locales/zh/task-drawer/task-drawer-info-tab.json create mode 100644 worklenz-frontend/public/locales/zh/task-drawer/task-drawer.json create mode 100644 worklenz-frontend/public/locales/zh/task-list-filters.json create mode 100644 worklenz-frontend/public/locales/zh/task-list-table.json create mode 100644 worklenz-frontend/public/locales/zh/task-template-drawer.json create mode 100644 worklenz-frontend/public/locales/zh/tasks/task-table-bulk-actions.json create mode 100644 worklenz-frontend/public/locales/zh/template-drawer.json create mode 100644 worklenz-frontend/public/locales/zh/templateDrawer.json create mode 100644 worklenz-frontend/public/locales/zh/time-report.json create mode 100644 worklenz-frontend/public/locales/zh/unauthorized.json diff --git a/worklenz-backend/database/sql/1_tables.sql b/worklenz-backend/database/sql/1_tables.sql index e9fc31c4..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', 'alb', 'de'); +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-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/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..7ce20f0b --- /dev/null +++ b/worklenz-frontend/public/locales/zh/project-view/project-view-header.json @@ -0,0 +1,13 @@ +{ + "importTasks": "导入任务", + "createTask": "创建任务", + "settings": "设置", + "subscribe": "订阅", + "unsubscribe": "取消订阅", + "deleteProject": "删除项目", + "startDate": "开始日期", + "endDate": "结束日期", + "projectSettings": "项目设置", + "projectSummary": "项目摘要", + "receiveProjectSummary": "每晚接收项目摘要。" +} \ 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..79e670c6 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/profile.json @@ -0,0 +1,13 @@ +{ + "uploadError": "您只能上传JPG/PNG文件!", + "uploadSizeError": "图片必须小于2MB!", + "upload": "上传", + "nameLabel": "名称", + "nameRequiredError": "名称是必需的", + "emailLabel": "电子邮件", + "emailRequiredError": "电子邮件是必需的", + "saveChanges": "保存更改", + "profileJoinedText": "一个月前加入", + "profileLastUpdatedText": "一个月前更新", + "avatarTooltip": "点击上传头像" +} \ 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..ad5e9a7d --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/sidebar.json @@ -0,0 +1,14 @@ +{ + "profile": "个人资料", + "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..5826c6ec --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/team-members.json @@ -0,0 +1,44 @@ +{ + "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": "输入电子邮件地址并按回车键..." +} \ 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..8ac1c0d1 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/task-drawer/task-drawer.json @@ -0,0 +1,78 @@ +{ + "taskHeader": { + "taskNamePlaceholder": "输入您的任务", + "deleteTask": "删除任务" + }, + "taskInfoTab": { + "title": "信息", + "details": { + "title": "详情", + "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": "分钟" + }, + "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": "您确定要删除此评论吗?" + }, + "searchInputPlaceholder": "按名称搜索", + "pendingInvitation": "待处理邀请" + }, + "taskTimeLogTab": { + "title": "时间日志", + "addTimeLog": "添加新时间日志", + "totalLogged": "总记录", + "exportToExcel": "导出到Excel", + "noTimeLogsFound": "未找到时间日志" + }, + "taskActivityLogTab": { + "title": "活动日志" + } +} \ 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..300c8eb0 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/task-list-filters.json @@ -0,0 +1,54 @@ +{ + "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": "创建" +} \ 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..c380963e --- /dev/null +++ b/worklenz-frontend/public/locales/zh/task-list-table.json @@ -0,0 +1,56 @@ +{ + "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": "输入任务并按回车键", + "openButton": "打开", + "okButton": "确定", + "noLabelsFound": "未找到标签", + "searchInputPlaceholder": "搜索或创建", + "assigneeSelectorInviteButton": "通过电子邮件邀请新成员", + "labelInputPlaceholder": "搜索或创建", + "pendingInvitation": "待处理邀请", + "contextMenu": { + "assignToMe": "分配给我", + "moveTo": "移动到", + "unarchive": "取消归档", + "archive": "归档", + "convertToSubTask": "转换为子任务", + "convertToTask": "转换为任务", + "delete": "删除", + "searchByNameInputPlaceholder": "按名称搜索" + } +} \ 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/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/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 a043ac95..2bc4bf2f 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 }) => { @@ -150,7 +154,7 @@ const LanguageAndRegionSettings = () => { {t('save_changes')} - ): ( + ) : ( )} diff --git a/worklenz-frontend/src/utils/greetingString.ts b/worklenz-frontend/src/utils/greetingString.ts index 1e122f6a..d81bd1f2 100644 --- a/worklenz-frontend/src/utils/greetingString.ts +++ b/worklenz-frontend/src/utils/greetingString.ts @@ -41,6 +41,12 @@ export const greetingString = (name: string): string => { morning = 'Morgen'; afternoon = 'Tag'; evening = 'Abend'; + } else if (language === 'zh_cn') { + greetingPrefix = '你好'; + greetingSuffix = ''; + morning = '早上好'; + afternoon = '下午好'; + evening = '晚上好'; } return `${greetingPrefix} ${name}, ${greetingSuffix} ${greet}!`; From a6f9046b42ce8871751d31461dafc173e2c027e1 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 7 Jul 2025 09:40:56 +0530 Subject: [PATCH 09/39] feat(task-management): add all_labels support and improve label handling - Introduced all_labels property in task management to provide a complete list of labels for selection logic. - Updated TasksControllerV2, TaskRow, and LabelsSelector components to utilize all_labels for enhanced label management. - Improved checkbox handling in LabelsSelector to prevent event propagation and ensure better user interaction. - Enhanced useTaskSocketHandlers to manage temporary subtasks effectively, preventing duplication during optimistic updates. --- .../src/controllers/tasks-controller-v2.ts | 5 ++++ .../src/components/LabelsSelector.tsx | 19 ++++++++----- .../src/components/task-list-v2/TaskRow.tsx | 8 +++--- .../task-management/task-management.slice.ts | 5 ++++ .../src/hooks/useTaskSocketHandlers.ts | 27 ++++++++++++++++++- .../src/types/task-management.types.ts | 1 + 6 files changed, 53 insertions(+), 12 deletions(-) diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 162d5dd2..3a575bba 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -1078,6 +1078,11 @@ export default class TasksControllerV2 extends TasksControllerBase { end: l.end, names: l.names })) || [], + all_labels: task.all_labels?.map((l: any) => ({ + id: l.id || l.label_id, + name: l.name, + color_code: l.color_code || "#1890ff" + })) || [], dueDate: task.end_date || task.END_DATE, startDate: task.start_date, timeTracking: { diff --git a/worklenz-frontend/src/components/LabelsSelector.tsx b/worklenz-frontend/src/components/LabelsSelector.tsx index 960970ae..37f39c78 100644 --- a/worklenz-frontend/src/components/LabelsSelector.tsx +++ b/worklenz-frontend/src/components/LabelsSelector.tsx @@ -223,16 +223,21 @@ const LabelsSelector: React.FC = ({ task, isDarkMode = fals
handleLabelToggle(label)} + onClick={(e) => { + e.stopPropagation(); + handleLabelToggle(label); + }} > - handleLabelToggle(label)} - isDarkMode={isDarkMode} - /> +
+ {}} // Empty handler since we handle click on the div + isDarkMode={isDarkMode} + /> +
= memo(({ taskId, projectId, visibleColumn name: task.title || task.name, parent_task_id: task.parent_task_id, manual_progress: false, - all_labels: task.labels?.map(label => ({ + all_labels: task.all_labels?.map(label => ({ id: label.id, name: label.name, - color_code: label.color, + color_code: label.color_code, })) || [], labels: task.labels?.map(label => ({ id: label.id, name: label.name, color_code: label.color, })) || [], - }), [task.id, task.title, task.name, task.parent_task_id, task.labels, task.labels?.length]); + }), [task.id, task.title, task.name, task.parent_task_id, task.all_labels, task.labels, task.all_labels?.length, task.labels?.length]); // Handle checkbox change const handleCheckboxChange = useCallback((e: any) => { @@ -556,7 +556,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn case 'labels': return ( -
+
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 7a7a0177..08febf40 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -271,6 +271,11 @@ export const fetchTasksV3 = createAsyncThunk( end: l.end, names: l.names, })) || [], + all_labels: task.all_labels?.map((l: { id: string; label_id: string; name: string; color_code: string }) => ({ + id: l.id || l.label_id, + name: l.name, + color_code: l.color_code || '#1890ff', + })) || [], dueDate: task.dueDate, startDate: task.startDate, timeTracking: { diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index c1696ae4..cbcb46c1 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -43,6 +43,7 @@ import { selectCurrentGroupingV3, fetchTasksV3, addSubtaskToParent, + removeTemporarySubtask, } from '@/features/task-management/task-management.slice'; import { updateEnhancedKanbanSubtask, @@ -153,13 +154,19 @@ export const useTaskSocketHandlers = () => { const updatedTask: Task = { ...currentTask, labels: - labels.all_labels?.map(l => ({ + labels.labels?.map(l => ({ id: l.id || '', name: l.name || '', color: l.color_code || '#1890ff', end: l.end, names: l.names, })) || [], + all_labels: + labels.all_labels?.map(l => ({ + id: l.id || '', + name: l.name || '', + color_code: l.color_code || '#1890ff', + })) || [], updatedAt: new Date().toISOString(), updated_at: new Date().toISOString(), }; @@ -675,6 +682,24 @@ export const useTaskSocketHandlers = () => { parent_task_id: data.parent_task_id, is_sub_task: true, }; + + // Before adding the real subtask, remove any temporary subtasks with the same name + // This prevents duplication from optimistic updates + const parentTask = store.getState().taskManagement.entities[data.parent_task_id]; + if (parentTask && parentTask.sub_tasks) { + const temporarySubtasks = parentTask.sub_tasks.filter( + (st: Task) => st.isTemporary && st.name === subtask.title + ); + + // Remove each temporary subtask + temporarySubtasks.forEach((tempSubtask: Task) => { + dispatch(removeTemporarySubtask({ + parentTaskId: data.parent_task_id, + tempId: tempSubtask.id + })); + }); + } + dispatch(addSubtaskToParent({ parentId: data.parent_task_id, subtask })); // Also update enhanced kanban slice for subtask creation diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index 519b10d2..32518142 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -33,6 +33,7 @@ export interface Task { statusColor?: string; priorityColor?: string; labels?: { id: string; name: string; color: string; end?: boolean; names?: string[] }[]; + all_labels?: { id: string; name: string; color_code: string }[]; // Complete list of labels for selection logic comments_count?: number; attachments_count?: number; has_dependencies?: boolean; From 26b47aac5311dfb020bd11a673fe7aa8ccc251a1 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 7 Jul 2025 12:31:11 +0530 Subject: [PATCH 10/39] refactor(i18n): optimize translation loading and initialization - Updated ensureTranslationsLoaded function to prevent duplicate requests by caching loaded translations and managing loading promises. - Simplified translation preloading on app startup to only load essential namespaces for the current language. - Adjusted useTranslationPreloader hook to avoid multiple requests for translations and ensure efficient loading state management. --- worklenz-frontend/src/App.tsx | 5 +- .../src/hooks/useTranslationPreloader.ts | 12 ++- worklenz-frontend/src/i18n.ts | 96 ++++++++++++++----- 3 files changed, 81 insertions(+), 32 deletions(-) diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 37a581b6..0658c25c 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'; @@ -66,8 +65,8 @@ const App: React.FC = memo(() => { // Initialize CSRF token await initializeCsrfToken(); - // Preload essential translations - await ensureTranslationsLoaded(); + // Note: Translation preloading is handled in i18n.ts initialization + // No need to call ensureTranslationsLoaded here to avoid duplicate requests } catch (error) { if (isMounted) { logger.error('Failed to initialize app:', error); 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..eebcb6d2 100644 --- a/worklenz-frontend/src/i18n.ts +++ b/worklenz-frontend/src/i18n.ts @@ -12,6 +12,10 @@ const ESSENTIAL_NAMESPACES = [ 'settings', ]; +// Cache to track loaded translations and prevent duplicate requests +const loadedTranslations = new Set(); +const loadingPromises = new Map>(); + i18n .use(HttpApi) .use(initReactI18next) @@ -37,33 +41,67 @@ i18n }); // Utility function to ensure translations are loaded -export const ensureTranslationsLoaded = async (namespaces: string[] = ESSENTIAL_NAMESPACES) => { - const currentLang = i18n.language || 'en'; - +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) => { + // Switch to the target language temporarily if needed + const currentLang = i18n.language; + const shouldSwitchLang = currentLang !== lang; + + const loadForLanguage = async () => { + try { + if (shouldSwitchLang) { + await i18n.changeLanguage(lang); + } + + await i18n.loadNamespaces(ns); + + // Switch back to original language if we changed it + 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); + } + } + + // Wait for all loading promises to complete + await Promise.all(loadPromises); return true; } catch (error) { logger.error('Failed to load translations:', error); @@ -71,7 +109,13 @@ export const ensureTranslationsLoaded = async (namespaces: string[] = ESSENTIAL_ } }; -// Initialize translations on app startup -ensureTranslationsLoaded(); +// Preload essential translations for current language only on startup +const initializeTranslations = async () => { + const currentLang = i18n.language || 'en'; + await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [currentLang]); +}; + +// Initialize translations on app startup (only once) +initializeTranslations(); export default i18n; From aa1fb1c6f5cd15e0400de277ef65f3b387ba16cb Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 7 Jul 2025 12:41:23 +0530 Subject: [PATCH 11/39] feat(performance): optimize resource loading and initialization - Added resource hints in index.html for improved loading performance, including preconnect and dns-prefetch links. - Implemented preload for critical JSON resources to enhance initial load times. - Optimized Google Analytics and HubSpot script loading to defer execution and reduce blocking during initial render. - Refactored app initialization in App.tsx to defer non-critical operations, improving perceived performance. - Introduced lazy loading for chart components and TinyMCE editor to minimize initial bundle size and enhance user experience. - Enhanced Vite configuration for optimized chunking strategy and improved caching with shorter hash lengths. --- worklenz-frontend/index.html | 93 ++++++--- worklenz-frontend/src/App.tsx | 48 ++++- .../components/charts/LazyChartComponents.tsx | 84 ++++++++ .../shared/info-tab/description-editor.tsx | 197 +++++++++++------- worklenz-frontend/src/i18n.ts | 121 +++++++++-- .../project-time-sheet-chart.tsx | 99 +++++++-- worklenz-frontend/vite.config.ts | 118 +++++++++-- 7 files changed, 583 insertions(+), 177 deletions(-) create mode 100644 worklenz-frontend/src/components/charts/LazyChartComponents.tsx diff --git a/worklenz-frontend/index.html b/worklenz-frontend/index.html index a2f637b2..0b2b7f18 100644 --- a/worklenz-frontend/index.html +++ b/worklenz-frontend/index.html @@ -5,43 +5,74 @@ + + + + + + + + + + + + + Worklenz + - + + diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 0658c25c..9fdd1605 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -29,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); @@ -37,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 @@ -48,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(); - - // Note: Translation preloading is handled in i18n.ts initialization - // No need to call ensureTranslationsLoaded here to avoid duplicate requests } 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/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/task-drawer/shared/info-tab/description-editor.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/description-editor.tsx index 19a808e8..470a18c9 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/description-editor.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/description-editor.tsx @@ -1,10 +1,14 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { Editor } from '@tinymce/tinymce-react'; +import React, { useState, useRef, useEffect, lazy, Suspense } from 'react'; import DOMPurify from 'dompurify'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; +// Lazy load TinyMCE editor to reduce initial bundle size +const LazyTinyMCEEditor = lazy(() => + import('@tinymce/tinymce-react').then(module => ({ default: module.Editor })) +); + interface DescriptionEditorProps { description: string | null; taskId: string; @@ -17,23 +21,39 @@ 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); - }; - - 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.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; @@ -80,7 +100,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 +109,14 @@ 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 darkModeStyles = @@ -141,59 +159,63 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
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/i18n.ts b/worklenz-frontend/src/i18n.ts index eebcb6d2..8c96cd62 100644 --- a/worklenz-frontend/src/i18n.ts +++ b/worklenz-frontend/src/i18n.ts @@ -6,16 +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) @@ -23,24 +34,34 @@ 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 +// Optimized function to ensure translations are loaded export const ensureTranslationsLoaded = async ( namespaces: string[] = ESSENTIAL_NAMESPACES, languages: string[] = [i18n.language || 'en'] @@ -65,7 +86,6 @@ export const ensureTranslationsLoaded = async ( // Create loading promise const loadingPromise = new Promise((resolve, reject) => { - // Switch to the target language temporarily if needed const currentLang = i18n.language; const shouldSwitchLang = currentLang !== lang; @@ -77,7 +97,6 @@ export const ensureTranslationsLoaded = async ( await i18n.loadNamespaces(ns); - // Switch back to original language if we changed it if (shouldSwitchLang && currentLang) { await i18n.changeLanguage(currentLang); } @@ -100,7 +119,6 @@ export const ensureTranslationsLoaded = async ( } } - // Wait for all loading promises to complete await Promise.all(loadPromises); return true; } catch (error) { @@ -109,13 +127,86 @@ export const ensureTranslationsLoaded = async ( } }; -// Preload essential translations for current language only on startup -const initializeTranslations = async () => { - const currentLang = i18n.language || 'en'; - await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [currentLang]); +// 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; + } }; -// Initialize translations on app startup (only once) +// 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/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/vite.config.ts b/worklenz-frontend/vite.config.ts index e18e0eb0..e3fa7ed5 100644 --- a/worklenz-frontend/vite.config.ts +++ b/worklenz-frontend/vite.config.ts @@ -77,36 +77,90 @@ export default defineConfig(({ command, mode }) => { // **Rollup Options** rollupOptions: { output: { - // **Simplified Chunking Strategy to avoid React context issues** - manualChunks: { - // Keep React and all React-dependent libraries together - 'react-vendor': ['react', 'react-dom', 'react/jsx-runtime'], - - // Separate chunk for router - 'react-router': ['react-router-dom'], - - // Keep Ant Design separate but ensure React is available - antd: ['antd', '@ant-design/icons'], + // **Optimized Chunking Strategy for better caching and loading** + manualChunks: (id) => { + // Core React libraries - most stable, rarely change + if (id.includes('react') || id.includes('react-dom') || id.includes('react/jsx-runtime')) { + return 'react-core'; + } + + // React Router - separate chunk as it's used throughout the app + if (id.includes('react-router') || id.includes('react-router-dom')) { + return 'react-router'; + } + + // Ant Design - large UI library, separate chunk + if (id.includes('antd') || id.includes('@ant-design')) { + return 'antd'; + } + + // Chart.js and related libraries - heavy visualization libs + if (id.includes('chart.js') || id.includes('react-chartjs') || id.includes('chartjs')) { + return 'charts'; + } + + // TinyMCE - heavy editor, separate chunk (lazy loaded) + if (id.includes('tinymce') || id.includes('@tinymce')) { + return 'tinymce'; + } + + // Gantt and scheduling libraries - heavy components + if (id.includes('gantt') || id.includes('scheduler')) { + return 'gantt'; + } + + // Date utilities - commonly used + if (id.includes('date-fns') || id.includes('moment')) { + return 'date-utils'; + } + + // Redux and state management + if (id.includes('@reduxjs') || id.includes('react-redux') || id.includes('redux')) { + return 'redux'; + } + + // Socket.io - real-time communication + if (id.includes('socket.io')) { + return 'socket'; + } + + // Utility libraries + if (id.includes('lodash') || id.includes('dompurify') || id.includes('nanoid')) { + return 'utils'; + } + + // i18n libraries + if (id.includes('i18next') || id.includes('react-i18next')) { + return 'i18n'; + } + + // Other node_modules dependencies + if (id.includes('node_modules')) { + return 'vendor'; + } + + // Return undefined for app code to be bundled together + return undefined; }, // **File Naming Strategies** - chunkFileNames: chunkInfo => { - const facadeModuleId = chunkInfo.facadeModuleId - ? chunkInfo.facadeModuleId.split('/').pop() - : 'chunk'; - return `assets/js/[name]-[hash].js`; + chunkFileNames: (chunkInfo) => { + // Use shorter names for better caching + return `assets/js/[name]-[hash:8].js`; }, - entryFileNames: 'assets/js/[name]-[hash].js', - assetFileNames: assetInfo => { - if (!assetInfo.name) return 'assets/[name]-[hash].[ext]'; + entryFileNames: 'assets/js/[name]-[hash:8].js', + assetFileNames: (assetInfo) => { + if (!assetInfo.name) return 'assets/[name]-[hash:8].[ext]'; const info = assetInfo.name.split('.'); let extType = info[info.length - 1]; - if (/png|jpe?g|svg|gif|tiff|bmp|ico/i.test(extType)) { + if (/png|jpe?g|svg|gif|tiff|bmp|ico|webp/i.test(extType)) { extType = 'img'; } else if (/woff2?|eot|ttf|otf/i.test(extType)) { extType = 'fonts'; + } else if (/css/i.test(extType)) { + extType = 'css'; } - return `assets/${extType}/[name]-[hash].[ext]`; + return `assets/${extType}/[name]-[hash:8].[ext]`; }, }, @@ -126,17 +180,35 @@ export default defineConfig(({ command, mode }) => { // **Optimization** optimizeDeps: { - include: ['react', 'react-dom', 'react/jsx-runtime', 'antd', '@ant-design/icons'], + include: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'antd', + '@ant-design/icons', + 'react-router-dom', + 'i18next', + 'react-i18next', + 'date-fns', + 'dompurify', + ], exclude: [ - // Add any packages that should not be pre-bundled + // Exclude heavy libraries that should be lazy loaded + '@tinymce/tinymce-react', + 'tinymce', + 'chart.js', + 'react-chartjs-2', + 'gantt-task-react', ], // Force pre-bundling to avoid runtime issues - force: true, + force: false, // Only force when needed to improve dev startup time }, // **Define global constants** define: { __DEV__: !isProduction, }, + + }; }); From bc085926a62e0e9bb8490a1e23c72489298fa6ab Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 7 Jul 2025 12:47:19 +0530 Subject: [PATCH 12/39] refactor(vite.config): clean up unnecessary whitespace in configuration file --- worklenz-frontend/vite.config.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/worklenz-frontend/vite.config.ts b/worklenz-frontend/vite.config.ts index e3fa7ed5..25b3b72d 100644 --- a/worklenz-frontend/vite.config.ts +++ b/worklenz-frontend/vite.config.ts @@ -208,7 +208,5 @@ export default defineConfig(({ command, mode }) => { define: { __DEV__: !isProduction, }, - - }; }); From bdc3050a5ea6bb3f876dde89f58da0d490465fde Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 7 Jul 2025 13:10:27 +0530 Subject: [PATCH 13/39] feat(database): add performance indexes for optimized task queries - Introduced a new SQL migration file to create various performance indexes on tasks, task_assignees, task_phase, and related tables. - These indexes aim to enhance query performance for task filtering, status joins, assignees lookup, and other operations, improving overall application efficiency. --- .../20250115000000-performance-indexes.sql | 82 +++++ .../src/controllers/tasks-controller-v2.ts | 346 ++++++++++++------ 2 files changed, 314 insertions(+), 114 deletions(-) create mode 100644 worklenz-backend/database/migrations/20250115000000-performance-indexes.sql 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..4238498c --- /dev/null +++ b/worklenz-backend/database/migrations/20250115000000-performance-indexes.sql @@ -0,0 +1,82 @@ +-- 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); \ No newline at end of file diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 3a575bba..2bad811c 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -8,6 +8,7 @@ import { ServerResponse } from "../models/server-response"; import { TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../shared/constants"; import { getColor, log_error } from "../shared/utils"; import TasksControllerBase, { GroupBy, ITaskGroup } from "./tasks-controller-base"; +import { redisClient } from "../redis/client"; export class TaskListGroup implements ITaskGroup { name: string; @@ -131,31 +132,9 @@ 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"; @@ -179,94 +158,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 = "${userId}" + 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} `; @@ -995,11 +1051,42 @@ export default class TasksControllerV2 extends TasksControllerBase { // 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"; + + // CACHING OPTIMIZATION: Cache results for frequently accessed data + const cacheKey = `tasks_v3_${req.params.id}_${groupBy}_${JSON.stringify({ + parent_task: req.query.parent_task, + archived: req.query.archived, + search: req.query.search, + statuses: req.query.statuses, + priorities: req.query.priorities, + labels: req.query.labels, + members: req.query.members, + projects: req.query.projects, + filterBy: req.query.filterBy, + customColumns: req.query.customColumns, + isSubtasksInclude: req.query.isSubtasksInclude + })}`; + + // Try to get cached result first (only if not refreshing progress) + if (!shouldRefreshProgress) { + try { + const cachedResult = await redisClient.get(cacheKey); + if (cachedResult) { + const parsedResult = JSON.parse(cachedResult); + console.log(`[PERFORMANCE] Cache hit for project ${req.params.id} - returned in ${(performance.now() - startTime).toFixed(2)}ms`); + return res.status(200).send(parsedResult); + } + } catch (cacheError) { + console.warn("[CACHE WARNING] Redis cache read failed:", cacheError); + // Continue with normal query if cache fails + } + } if (shouldRefreshProgress && 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 queryStartTime = performance.now(); @@ -1009,11 +1096,13 @@ export default class TasksControllerV2 extends TasksControllerBase { const result = await db.query(q, params); const tasks = [...result.rows]; const queryEndTime = performance.now(); + console.log(`[PERFORMANCE] Main query completed in ${(queryEndTime - queryStartTime).toFixed(2)}ms for ${tasks.length} tasks`); // Get groups metadata dynamically from database const groupsStartTime = performance.now(); const groups = await this.getGroups(groupBy, req.params.id); const groupsEndTime = performance.now(); + console.log(`[PERFORMANCE] Groups query completed in ${(groupsEndTime - groupsStartTime).toFixed(2)}ms`); // Create priority value to name mapping const priorityMap: Record = { @@ -1031,8 +1120,6 @@ export default class TasksControllerV2 extends TasksControllerBase { } } - - // Transform tasks with all necessary data preprocessing const transformStartTime = performance.now(); const transformedTasks = tasks.map((task, index) => { @@ -1229,12 +1316,26 @@ export default class TasksControllerV2 extends TasksControllerBase { console.warn(`[PERFORMANCE WARNING] Slow request detected: ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks`); } - return res.status(200).send(new ServerResponse(true, { + const responseData = new ServerResponse(true, { groups: responseGroups, allTasks: transformedTasks, grouping: groupBy, totalTasks: transformedTasks.length - })); + }); + + // Cache the result for 5 minutes (300 seconds) - only cache successful results + if (transformedTasks.length > 0) { + try { + await redisClient.setEx(cacheKey, 300, JSON.stringify(responseData)); + console.log(`[PERFORMANCE] Cached result for project ${req.params.id} with ${transformedTasks.length} tasks`); + } catch (cacheError) { + console.warn("[CACHE WARNING] Redis cache write failed:", cacheError); + // Continue even if cache write fails + } + } + + console.log(`[PERFORMANCE] getTasksV3 completed in ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks`); + return res.status(200).send(responseData); } private static getDefaultGroupColor(groupBy: string, groupValue: string): string { @@ -1332,4 +1433,21 @@ export default class TasksControllerV2 extends TasksControllerBase { return res.status(500).send(new ServerResponse(false, null, "Failed to get task progress status")); } } + + // Helper method to invalidate cache when tasks are modified + public static async invalidateTasksCache(projectId: string): Promise { + try { + // Get all cache keys for this project + const pattern = `tasks_v3_${projectId}_*`; + const keys = await redisClient.keys(pattern); + + if (keys.length > 0) { + await redisClient.del(keys); + console.log(`[CACHE] Invalidated ${keys.length} cache entries for project ${projectId}`); + } + } catch (error) { + console.warn("[CACHE WARNING] Cache invalidation failed:", error); + // Don't throw error - cache invalidation failure shouldn't break the operation + } + } } From a3f317cbeb0b1e4806d53eb29015da3dd4075cd9 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 7 Jul 2025 13:16:26 +0530 Subject: [PATCH 14/39] refactor(vite.config): simplify chunking strategy and optimize asset naming - Updated the chunking strategy to keep React and related libraries together, improving compatibility and reducing context issues. - Simplified asset naming conventions for better caching and consistency, removing unnecessary hash lengths. - Adjusted optimization settings to ensure critical libraries are included while excluding heavy libraries for lazy loading. --- worklenz-frontend/vite.config.ts | 118 +++++++------------------------ 1 file changed, 24 insertions(+), 94 deletions(-) diff --git a/worklenz-frontend/vite.config.ts b/worklenz-frontend/vite.config.ts index 25b3b72d..d0ba2dac 100644 --- a/worklenz-frontend/vite.config.ts +++ b/worklenz-frontend/vite.config.ts @@ -77,90 +77,36 @@ export default defineConfig(({ command, mode }) => { // **Rollup Options** rollupOptions: { output: { - // **Optimized Chunking Strategy for better caching and loading** - manualChunks: (id) => { - // Core React libraries - most stable, rarely change - if (id.includes('react') || id.includes('react-dom') || id.includes('react/jsx-runtime')) { - return 'react-core'; - } - - // React Router - separate chunk as it's used throughout the app - if (id.includes('react-router') || id.includes('react-router-dom')) { - return 'react-router'; - } - - // Ant Design - large UI library, separate chunk - if (id.includes('antd') || id.includes('@ant-design')) { - return 'antd'; - } - - // Chart.js and related libraries - heavy visualization libs - if (id.includes('chart.js') || id.includes('react-chartjs') || id.includes('chartjs')) { - return 'charts'; - } - - // TinyMCE - heavy editor, separate chunk (lazy loaded) - if (id.includes('tinymce') || id.includes('@tinymce')) { - return 'tinymce'; - } - - // Gantt and scheduling libraries - heavy components - if (id.includes('gantt') || id.includes('scheduler')) { - return 'gantt'; - } - - // Date utilities - commonly used - if (id.includes('date-fns') || id.includes('moment')) { - return 'date-utils'; - } - - // Redux and state management - if (id.includes('@reduxjs') || id.includes('react-redux') || id.includes('redux')) { - return 'redux'; - } - - // Socket.io - real-time communication - if (id.includes('socket.io')) { - return 'socket'; - } - - // Utility libraries - if (id.includes('lodash') || id.includes('dompurify') || id.includes('nanoid')) { - return 'utils'; - } - - // i18n libraries - if (id.includes('i18next') || id.includes('react-i18next')) { - return 'i18n'; - } - - // Other node_modules dependencies - if (id.includes('node_modules')) { - return 'vendor'; - } - - // Return undefined for app code to be bundled together - return undefined; + // **Simplified Chunking Strategy to avoid React context issues** + manualChunks: { + // Keep React and all React-dependent libraries together + 'react-vendor': ['react', 'react-dom', 'react/jsx-runtime'], + + // Separate chunk for router + 'react-router': ['react-router-dom'], + + // Keep Ant Design separate but ensure React is available + antd: ['antd', '@ant-design/icons'], }, // **File Naming Strategies** - chunkFileNames: (chunkInfo) => { - // Use shorter names for better caching - return `assets/js/[name]-[hash:8].js`; + chunkFileNames: chunkInfo => { + const facadeModuleId = chunkInfo.facadeModuleId + ? chunkInfo.facadeModuleId.split('/').pop() + : 'chunk'; + return `assets/js/[name]-[hash].js`; }, - entryFileNames: 'assets/js/[name]-[hash:8].js', - assetFileNames: (assetInfo) => { - if (!assetInfo.name) return 'assets/[name]-[hash:8].[ext]'; + entryFileNames: 'assets/js/[name]-[hash].js', + assetFileNames: assetInfo => { + if (!assetInfo.name) return 'assets/[name]-[hash].[ext]'; const info = assetInfo.name.split('.'); let extType = info[info.length - 1]; - if (/png|jpe?g|svg|gif|tiff|bmp|ico|webp/i.test(extType)) { + if (/png|jpe?g|svg|gif|tiff|bmp|ico/i.test(extType)) { extType = 'img'; } else if (/woff2?|eot|ttf|otf/i.test(extType)) { extType = 'fonts'; - } else if (/css/i.test(extType)) { - extType = 'css'; } - return `assets/${extType}/[name]-[hash:8].[ext]`; + return `assets/${extType}/[name]-[hash].[ext]`; }, }, @@ -180,28 +126,12 @@ export default defineConfig(({ command, mode }) => { // **Optimization** optimizeDeps: { - include: [ - 'react', - 'react-dom', - 'react/jsx-runtime', - 'antd', - '@ant-design/icons', - 'react-router-dom', - 'i18next', - 'react-i18next', - 'date-fns', - 'dompurify', - ], + include: ['react', 'react-dom', 'react/jsx-runtime', 'antd', '@ant-design/icons'], exclude: [ - // Exclude heavy libraries that should be lazy loaded - '@tinymce/tinymce-react', - 'tinymce', - 'chart.js', - 'react-chartjs-2', - 'gantt-task-react', + // Add any packages that should not be pre-bundled ], // Force pre-bundling to avoid runtime issues - force: false, // Only force when needed to improve dev startup time + force: true, }, // **Define global constants** @@ -209,4 +139,4 @@ export default defineConfig(({ command, mode }) => { __DEV__: !isProduction, }, }; -}); +}); \ No newline at end of file From 0b96d59285ef3d268ea104718c52aa0004279ba4 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 7 Jul 2025 13:22:17 +0530 Subject: [PATCH 15/39] refactor(tasks-controller): remove Redis caching logic for task retrieval - Eliminated caching logic from the TasksControllerV2 to streamline task retrieval and improve performance. - Updated response handling to directly return task data without caching, enhancing clarity and reducing complexity. - Removed cache invalidation method to simplify the controller's responsibilities. --- .../src/controllers/tasks-controller-v2.ts | 68 ++----------------- 1 file changed, 5 insertions(+), 63 deletions(-) diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 2bad811c..f1616740 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -8,7 +8,6 @@ import { ServerResponse } from "../models/server-response"; import { TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../shared/constants"; import { getColor, log_error } from "../shared/utils"; import TasksControllerBase, { GroupBy, ITaskGroup } from "./tasks-controller-base"; -import { redisClient } from "../redis/client"; export class TaskListGroup implements ITaskGroup { name: string; @@ -1051,36 +1050,6 @@ export default class TasksControllerV2 extends TasksControllerBase { // 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"; - - // CACHING OPTIMIZATION: Cache results for frequently accessed data - const cacheKey = `tasks_v3_${req.params.id}_${groupBy}_${JSON.stringify({ - parent_task: req.query.parent_task, - archived: req.query.archived, - search: req.query.search, - statuses: req.query.statuses, - priorities: req.query.priorities, - labels: req.query.labels, - members: req.query.members, - projects: req.query.projects, - filterBy: req.query.filterBy, - customColumns: req.query.customColumns, - isSubtasksInclude: req.query.isSubtasksInclude - })}`; - - // Try to get cached result first (only if not refreshing progress) - if (!shouldRefreshProgress) { - try { - const cachedResult = await redisClient.get(cacheKey); - if (cachedResult) { - const parsedResult = JSON.parse(cachedResult); - console.log(`[PERFORMANCE] Cache hit for project ${req.params.id} - returned in ${(performance.now() - startTime).toFixed(2)}ms`); - return res.status(200).send(parsedResult); - } - } catch (cacheError) { - console.warn("[CACHE WARNING] Redis cache read failed:", cacheError); - // Continue with normal query if cache fails - } - } if (shouldRefreshProgress && req.params.id) { const progressStartTime = performance.now(); @@ -1316,26 +1285,14 @@ export default class TasksControllerV2 extends TasksControllerBase { console.warn(`[PERFORMANCE WARNING] Slow request detected: ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks`); } - const responseData = new ServerResponse(true, { + console.log(`[PERFORMANCE] getTasksV3 completed in ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks`); + + return res.status(200).send(new ServerResponse(true, { groups: responseGroups, allTasks: transformedTasks, grouping: groupBy, totalTasks: transformedTasks.length - }); - - // Cache the result for 5 minutes (300 seconds) - only cache successful results - if (transformedTasks.length > 0) { - try { - await redisClient.setEx(cacheKey, 300, JSON.stringify(responseData)); - console.log(`[PERFORMANCE] Cached result for project ${req.params.id} with ${transformedTasks.length} tasks`); - } catch (cacheError) { - console.warn("[CACHE WARNING] Redis cache write failed:", cacheError); - // Continue even if cache write fails - } - } - - console.log(`[PERFORMANCE] getTasksV3 completed in ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks`); - return res.status(200).send(responseData); + })); } private static getDefaultGroupColor(groupBy: string, groupValue: string): string { @@ -1434,20 +1391,5 @@ export default class TasksControllerV2 extends TasksControllerBase { } } - // Helper method to invalidate cache when tasks are modified - public static async invalidateTasksCache(projectId: string): Promise { - try { - // Get all cache keys for this project - const pattern = `tasks_v3_${projectId}_*`; - const keys = await redisClient.keys(pattern); - - if (keys.length > 0) { - await redisClient.del(keys); - console.log(`[CACHE] Invalidated ${keys.length} cache entries for project ${projectId}`); - } - } catch (error) { - console.warn("[CACHE WARNING] Cache invalidation failed:", error); - // Don't throw error - cache invalidation failure shouldn't break the operation - } - } + } From 3887cc477d4bab9a3b8400fa656bf80ba9379d4c Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 7 Jul 2025 14:10:04 +0530 Subject: [PATCH 16/39] refactor(tasks-controller): enhance SQL query structure and improve task filtering - Updated SQL queries in TasksControllerV2 to use table aliases for clarity and consistency. - Improved filtering logic for project IDs and assignees to ensure accurate task retrieval. - Refactored JSON object construction in SQL queries for better readability and maintainability. - Adjusted parameter handling in query execution to streamline data retrieval processes. --- .../src/controllers/tasks-controller-v2.ts | 80 +++++++++---------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index f1616740..ad7456ee 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) { @@ -136,14 +136,14 @@ export default class TasksControllerV2 extends TasksControllerBase { ? `, 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 = [ @@ -186,18 +186,18 @@ export default class TasksControllerV2 extends TasksControllerBase { 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) + '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 + 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 @@ -208,16 +208,16 @@ export default class TasksControllerV2 extends TasksControllerBase { 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 + '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 + '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 @@ -241,7 +241,7 @@ export default class TasksControllerV2 extends TasksControllerBase { 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, @@ -270,11 +270,11 @@ export default class TasksControllerV2 extends TasksControllerBase { -- 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, + 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, @@ -286,11 +286,11 @@ export default class TasksControllerV2 extends TasksControllerBase { -- 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, + 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, @@ -317,7 +317,7 @@ export default class TasksControllerV2 extends TasksControllerBase { 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 = "${userId}" + 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 @@ -402,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]; @@ -510,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[] = []; @@ -1060,7 +1060,7 @@ export default class TasksControllerV2 extends TasksControllerBase { const queryStartTime = performance.now(); 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]; @@ -1126,7 +1126,7 @@ export default class TasksControllerV2 extends TasksControllerBase { 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, From 8533a440bc7b23700ed2a2877bca8470224ddb4a Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 7 Jul 2025 14:30:19 +0530 Subject: [PATCH 17/39] refactor(tasks-controller): enhance getTasksV3 method for performance and clarity - Improved logging for performance tracking in the getTasksV3 method. - Streamlined progress refresh logic and removed unnecessary calculations to optimize initial load times. - Unified query handling by aligning with the getList method for consistency. - Transformed response structure to maintain compatibility with V3 format while ensuring efficient data processing. - Added memoized selectors in the frontend for better performance and reduced re-renders. --- .../src/controllers/tasks-controller-v2.ts | 221 ++++++------------ .../task-management/grouping.slice.ts | 9 +- .../task-management/task-management.slice.ts | 28 ++- 3 files changed, 101 insertions(+), 157 deletions(-) diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index ad7456ee..7632ff82 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -1041,61 +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.user?.id] : [req.params.id || null, req.user?.id]; const result = await db.query(q, params); const tasks = [...result.rows]; - const queryEndTime = performance.now(); - console.log(`[PERFORMANCE] Main query completed in ${(queryEndTime - queryStartTime).toFixed(2)}ms for ${tasks.length} tasks`); - // 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(); - console.log(`[PERFORMANCE] Groups query completed in ${(groupsEndTime - groupsStartTime).toFixed(2)}ms`); + 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; @@ -1118,11 +1119,8 @@ 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) || [], @@ -1146,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, @@ -1165,128 +1163,55 @@ 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!`); } - console.log(`[PERFORMANCE] getTasksV3 completed in ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks`); - return res.status(200).send(new ServerResponse(true, { groups: responseGroups, allTasks: transformedTasks, 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; From 134899114d49cb89810c1c4a49c02cfe8e70b8fa Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 7 Jul 2025 14:50:01 +0530 Subject: [PATCH 18/39] refactor(index.html): update production tracking ID logic - Simplified the logic for determining the production tracking ID by removing the check for the 'worklenz.com' hostname. - Ensured that the tracking ID is now solely based on the 'app.worklenz.com' hostname, streamlining the analytics loading process. --- worklenz-frontend/index.html | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/worklenz-frontend/index.html b/worklenz-frontend/index.html index 0b2b7f18..1a945264 100644 --- a/worklenz-frontend/index.html +++ b/worklenz-frontend/index.html @@ -43,9 +43,7 @@ // Use requestIdleCallback to defer analytics loading const loadAnalytics = () => { // Determine which tracking ID to use based on the environment - const isProduction = - window.location.hostname === 'worklenz.com' || - window.location.hostname === 'app.worklenz.com'; + const isProduction = window.location.hostname === 'app.worklenz.com'; const trackingId = isProduction ? 'G-XXXXXXXXXX' : 'G-3LM2HGWEXG'; // Open source tracking ID From 978d9158c0de769ee998acc483a8e2704a30591f Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 7 Jul 2025 15:01:11 +0530 Subject: [PATCH 19/39] feat(database): add performance indexes and materialized view for optimization - Created multiple new indexes in the performance-indexes.sql file to enhance query performance for tasks, team members, and related tables. - Added a materialized view for team member information in 3_views.sql to pre-calculate expensive joins, improving data retrieval efficiency. - Introduced a function to refresh the materialized view, ensuring up-to-date information while optimizing performance. - Implemented an optimized method in tasks-controller-v2.ts to split complex queries into focused segments, significantly improving response times and overall performance. --- .../20250115000000-performance-indexes.sql | 55 +++- worklenz-backend/database/sql/3_views.sql | 34 ++ .../src/controllers/tasks-controller-v2.ts | 309 ++++++++++++++++++ 3 files changed, 397 insertions(+), 1 deletion(-) diff --git a/worklenz-backend/database/migrations/20250115000000-performance-indexes.sql b/worklenz-backend/database/migrations/20250115000000-performance-indexes.sql index 4238498c..791c6f02 100644 --- a/worklenz-backend/database/migrations/20250115000000-performance-indexes.sql +++ b/worklenz-backend/database/migrations/20250115000000-performance-indexes.sql @@ -79,4 +79,57 @@ ON task_priorities(value); -- Index for team labels CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_labels_team -ON team_labels(team_id); \ No newline at end of file +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/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 7632ff82..f5dcc666 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -1220,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]: { From b0253135e5c7b87fa4db8aad8af71022aacf3842 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 7 Jul 2025 17:07:45 +0530 Subject: [PATCH 20/39] feat(project-drawer): enhance project data fetching and error handling - Updated project data fetching logic in the project drawer and related components to ensure the drawer opens only after successful data retrieval. - Added detailed logging for successful and failed fetch attempts to improve debugging and user feedback. - Introduced error handling to maintain user experience by allowing the drawer to open even if data fetching fails, displaying an error state. - Refactored project list and project view components to optimize search functionality and improve loading states. - Removed deprecated components related to task management to streamline the project view. --- worklenz-frontend/package-lock.json | 26 + .../project-group/project-group-list.tsx | 19 +- .../project-list-actions.tsx | 19 +- .../project-drawer/project-drawer.tsx | 103 +++- .../features/project/project-drawer.slice.ts | 32 +- .../src/lib/project/project-view-constants.ts | 1 - .../src/pages/projects/project-list.tsx | 404 ++++++++---- .../projectView/board/project-view-board.tsx | 582 ------------------ .../project-view-enhanced-tasks.tsx | 19 - .../projectView/project-view-header.tsx | 19 +- .../taskList/project-view-task-list.tsx | 128 ---- .../taskList/task-group-wrapper-optimized.tsx | 82 --- 12 files changed, 450 insertions(+), 984 deletions(-) delete mode 100644 worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx delete mode 100644 worklenz-frontend/src/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks.tsx delete mode 100644 worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx delete mode 100644 worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json index 5e5154f3..0aba31ba 100644 --- a/worklenz-frontend/package-lock.json +++ b/worklenz-frontend/package-lock.json @@ -2314,6 +2314,32 @@ "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, + "node_modules/@tanstack/query-core": { + "version": "5.81.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.81.5.tgz", + "integrity": "sha512-ZJOgCy/z2qpZXWaj/oxvodDx07XcQa9BF92c0oINjHkoqUPsmm3uG08HpTaviviZ/N9eP1f9CM7mKSEkIo7O1Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.81.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.81.5.tgz", + "integrity": "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.81.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", 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 && ( )} - +
{ 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/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index bf6348a3..5edf1814 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -2,7 +2,6 @@ import React, { ReactNode, Suspense } from 'react'; import { InlineSuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; // 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'; diff --git a/worklenz-frontend/src/pages/projects/project-list.tsx b/worklenz-frontend/src/pages/projects/project-list.tsx index 7ea79ba7..a4477493 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,14 +369,15 @@ 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]); - - // Memoize the pagination show total function - const paginationShowTotal = useMemo( - () => (total: number, range: [number, number]) => `${range[0]}-${range[1]} of ${total} groups`, - [] - ); + // Handle query errors + useEffect(() => { + if (projectsError) { + console.error('Projects query error:', projectsError); + setErrorMessage('Failed to load projects. Please try again.'); + } else { + setErrorMessage(null); + } + }, [projectsError]); const handleTableChange = useCallback( ( @@ -262,135 +385,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,20 +653,19 @@ 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 only once + if (!groupedRequestParams.groupBy) { + dispatch(setGroupedRequestParams({ filter: filterIndex, index: 1, size: DEFAULT_PAGE_SIZE, @@ -580,29 +675,69 @@ const ProjectList: React.FC = () => { groupBy: '', statuses: null, categories: null, - }) - ); - }, [dispatch, getFilterIndex]); + })); + } + }, [dispatch, getFilterIndex]); // Remove requestParams and groupedRequestParams from deps to avoid loops + // 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 + // Optimized effect for grouped projects - only fetch when necessary useEffect(() => { - if (viewMode === ProjectViewType.GROUP && groupBy) { + if (viewMode === ProjectViewType.GROUP && groupBy && groupedRequestParams.groupBy) { dispatch(fetchGroupedProjects(groupedRequestParams)); } }, [dispatch, viewMode, groupBy, groupedRequestParams]); + // 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 +773,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 +797,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/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..62db71fe 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]); diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx deleted file mode 100644 index bff0a697..00000000 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useEffect, useState, useMemo } from 'react'; -import { Empty } from '@/shared/antd-imports'; -import Flex from 'antd/es/flex'; -import Skeleton from 'antd/es/skeleton'; -import { useSearchParams } from 'react-router-dom'; - -import TaskListFilters from './task-list-filters/task-list-filters'; -import TaskGroupWrapperOptimized from './task-group-wrapper-optimized'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { fetchTaskGroups, fetchTaskListColumns } from '@/features/tasks/tasks.slice'; -import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; -import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice'; -import useTabSearchParam from '@/hooks/useTabSearchParam'; - -const ProjectViewTaskList = () => { - 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); From a44b276269bf3664e356f585c0223ec0ee844813 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 8 Jul 2025 11:59:17 +0530 Subject: [PATCH 21/39] feat(email-templates): update release note template for Worklenz 2.1.0 - Added a title and meta subject for the release note template. - Enhanced styling for better readability and user experience, including background color, font adjustments, and button styles. - Introduced new sections for features, including a new tasks list, kanban board, group view, language support, and bug fixes. - Improved responsiveness and dark mode support for the email template. --- .../release-note-template.html | 439 ++++++++---------- worklenz-frontend/index.html | 2 +- .../src/pages/home/home-page.tsx | 27 +- .../src/pages/projects/project-list.tsx | 45 ++ 4 files changed, 255 insertions(+), 258 deletions(-) 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 1a945264..faeccff7 100644 --- a/worklenz-frontend/index.html +++ b/worklenz-frontend/index.html @@ -45,7 +45,7 @@ // Determine which tracking ID to use based on the environment const isProduction = window.location.hostname === 'app.worklenz.com'; - const trackingId = isProduction ? 'G-XXXXXXXXXX' : 'G-3LM2HGWEXG'; // Open source tracking ID + const trackingId = isProduction ? 'G-7KSRKQ1397' : 'G-3LM2HGWEXG'; // Open source tracking ID // Load the Google Analytics script const script = document.createElement('script'); 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 a4477493..aa2a4011 100644 --- a/worklenz-frontend/src/pages/projects/project-list.tsx +++ b/worklenz-frontend/src/pages/projects/project-list.tsx @@ -379,6 +379,51 @@ const ProjectList: React.FC = () => { } }, [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( + () => (total: number, range: [number, number]) => `${range[0]}-${range[1]} of ${total} groups`, + [] + ); + const handleTableChange = useCallback( ( newPagination: TablePaginationConfig, From 03b3f554008746bbf2091d170e4bded0836a3f83 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 8 Jul 2025 12:43:00 +0530 Subject: [PATCH 22/39] fix(project-list): enhance grouped request parameters handling - Updated the initialization of grouped request parameters to use a proper groupBy value. - Improved the effect dependencies to include groupBy, ensuring correct state management. - Enhanced the logic for fetching grouped projects, ensuring parameters are set correctly and data is retrieved when necessary. - Added comments for clarity on the conditions for fetching grouped projects. --- worklenz-frontend/package-lock.json | 567 +++++++++++++++++- .../src/pages/projects/project-list.tsx | 36 +- 2 files changed, 570 insertions(+), 33 deletions(-) diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json index 0aba31ba..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", @@ -2314,32 +2450,6 @@ "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, - "node_modules/@tanstack/query-core": { - "version": "5.81.5", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.81.5.tgz", - "integrity": "sha512-ZJOgCy/z2qpZXWaj/oxvodDx07XcQa9BF92c0oINjHkoqUPsmm3uG08HpTaviviZ/N9eP1f9CM7mKSEkIo7O1Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-query": { - "version": "5.81.5", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.81.5.tgz", - "integrity": "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.81.5" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, "node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", @@ -2881,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", @@ -3612,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", @@ -3651,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", @@ -3818,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", @@ -4288,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", @@ -4310,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", @@ -4357,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", @@ -4465,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", @@ -4546,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", @@ -5163,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", @@ -5217,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", @@ -5669,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", @@ -6805,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", @@ -6846,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", @@ -7236,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", @@ -7448,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", @@ -7466,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", @@ -7816,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", @@ -7837,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", @@ -7983,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/src/pages/projects/project-list.tsx b/worklenz-frontend/src/pages/projects/project-list.tsx index aa2a4011..e6df7839 100644 --- a/worklenz-frontend/src/pages/projects/project-list.tsx +++ b/worklenz-frontend/src/pages/projects/project-list.tsx @@ -708,8 +708,9 @@ const ProjectList: React.FC = () => { dispatch(setRequestParams(initialParams)); } - // Initialize grouped request params only once + // Initialize grouped request params with proper groupBy value if (!groupedRequestParams.groupBy) { + const initialGroupBy = groupBy || ProjectGroupBy.CATEGORY; dispatch(setGroupedRequestParams({ filter: filterIndex, index: 1, @@ -717,24 +718,45 @@ const ProjectList: React.FC = () => { field: 'name', order: 'ascend', search: '', - groupBy: '', + groupBy: initialGroupBy, statuses: null, categories: null, })); } - }, [dispatch, getFilterIndex]); // Remove requestParams and groupedRequestParams from deps to avoid loops + }, [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); }, [trackMixpanelEvent]); - // Optimized effect for grouped projects - only fetch when necessary + // Enhanced effect for grouped projects - fetch data when in group view useEffect(() => { - if (viewMode === ProjectViewType.GROUP && groupBy && groupedRequestParams.groupBy) { - dispatch(fetchGroupedProjects(groupedRequestParams)); + // 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) { + // 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(() => { From f06851fa378cecd6a4ccdf1265e1f539e1808653 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 8 Jul 2025 15:26:55 +0530 Subject: [PATCH 23/39] feat(localization): add and update translations for multiple languages - Introduced new localization files for Albanian, German, Spanish, Portuguese, and Chinese, enhancing the application's multilingual support. - Added new keys and updated existing translations in project-view, task-list-table, and settings files to improve user experience across different languages. - Enhanced error handling and empty state messages in task management components to provide clearer feedback to users. - Updated tooltip texts and button labels for better clarity and consistency in the user interface. --- .../public/locales/alb/project-view.json | 14 +++ .../alb/project-view/project-view-header.json | 20 ++++- .../public/locales/alb/settings/profile.json | 3 +- .../locales/alb/settings/team-members.json | 5 +- .../public/locales/alb/settings/teams.json | 16 ++++ .../public/locales/alb/task-list-table.json | 7 ++ .../public/locales/de/project-view.json | 14 +++ .../de/project-view/project-view-header.json | 16 +++- .../public/locales/de/settings/profile.json | 3 +- .../locales/de/settings/team-members.json | 5 +- .../public/locales/de/settings/teams.json | 16 ++++ .../public/locales/de/task-list-table.json | 7 ++ .../public/locales/en/project-view.json | 14 +++ .../en/project-view/project-view-header.json | 14 ++- .../public/locales/en/settings/profile.json | 3 +- .../locales/en/settings/team-members.json | 5 +- .../public/locales/en/settings/teams.json | 16 ++++ .../public/locales/en/task-list-table.json | 7 ++ .../public/locales/es/project-view.json | 14 +++ .../es/project-view/project-view-header.json | 20 ++++- .../public/locales/es/settings/profile.json | 3 +- .../locales/es/settings/team-members.json | 5 +- .../public/locales/es/settings/teams.json | 16 ++++ .../public/locales/es/task-list-table.json | 7 ++ .../public/locales/pt/project-view.json | 14 +++ .../pt/project-view/project-view-header.json | 18 +++- .../public/locales/pt/settings/profile.json | 3 +- .../locales/pt/settings/team-members.json | 5 +- .../public/locales/pt/settings/teams.json | 16 ++++ .../public/locales/pt/task-list-table.json | 7 ++ .../public/locales/zh/project-view.json | 14 +++ .../zh/project-view/project-view-header.json | 38 +++++--- .../public/locales/zh/settings/profile.json | 3 +- .../public/locales/zh/settings/sidebar.json | 1 + .../locales/zh/settings/team-members.json | 5 +- .../public/locales/zh/settings/teams.json | 16 ++++ .../public/locales/zh/task-list-filters.json | 14 ++- .../public/locales/zh/task-list-table.json | 70 +++++++++++++++ .../public/locales/zh/task-management.json | 35 ++++++++ .../src/components/PinRouteToNavbarButton.tsx | 11 ++- .../settings/edit-team-name-modal.tsx | 14 +-- .../settings/update-member-drawer.tsx | 2 +- .../components/task-drawer/task-drawer.tsx | 2 +- .../components/task-list-v2/TaskListV2.tsx | 8 +- .../task-management/improved-task-filters.tsx | 33 ++++++- .../src/lib/project/project-view-constants.ts | 44 ++++++++-- .../projectView/project-view-header.tsx | 88 +++++++++++-------- .../projects/projectView/project-view.tsx | 14 ++- .../settings/sidebar/settings-sidebar.tsx | 4 +- .../team-members/team-members-settings.tsx | 3 + .../pages/settings/teams/teams-settings.tsx | 19 ++-- .../src/utils/current-date-string.ts | 53 ++++++++++- worklenz-frontend/src/utils/greetingString.ts | 13 ++- 53 files changed, 700 insertions(+), 117 deletions(-) create mode 100644 worklenz-frontend/public/locales/alb/project-view.json create mode 100644 worklenz-frontend/public/locales/alb/settings/teams.json create mode 100644 worklenz-frontend/public/locales/de/project-view.json create mode 100644 worklenz-frontend/public/locales/de/settings/teams.json create mode 100644 worklenz-frontend/public/locales/en/project-view.json create mode 100644 worklenz-frontend/public/locales/en/settings/teams.json create mode 100644 worklenz-frontend/public/locales/es/project-view.json create mode 100644 worklenz-frontend/public/locales/es/settings/teams.json create mode 100644 worklenz-frontend/public/locales/pt/project-view.json create mode 100644 worklenz-frontend/public/locales/pt/settings/teams.json create mode 100644 worklenz-frontend/public/locales/zh/project-view.json create mode 100644 worklenz-frontend/public/locales/zh/settings/teams.json create mode 100644 worklenz-frontend/public/locales/zh/task-management.json 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-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-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-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-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-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/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/project-view-header.json b/worklenz-frontend/public/locales/zh/project-view/project-view-header.json index 7ce20f0b..ca0ead5c 100644 --- a/worklenz-frontend/public/locales/zh/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/zh/project-view/project-view-header.json @@ -1,13 +1,29 @@ { - "importTasks": "导入任务", - "createTask": "创建任务", - "settings": "设置", - "subscribe": "订阅", - "unsubscribe": "取消订阅", - "deleteProject": "删除项目", - "startDate": "开始日期", - "endDate": "结束日期", - "projectSettings": "项目设置", - "projectSummary": "项目摘要", - "receiveProjectSummary": "每晚接收项目摘要。" + "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/settings/profile.json b/worklenz-frontend/public/locales/zh/settings/profile.json index 79e670c6..cfafeb12 100644 --- a/worklenz-frontend/public/locales/zh/settings/profile.json +++ b/worklenz-frontend/public/locales/zh/settings/profile.json @@ -9,5 +9,6 @@ "saveChanges": "保存更改", "profileJoinedText": "一个月前加入", "profileLastUpdatedText": "一个月前更新", - "avatarTooltip": "点击上传头像" + "avatarTooltip": "点击上传头像", + "title": "个人资料设置" } \ 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 index ad5e9a7d..b9f74709 100644 --- a/worklenz-frontend/public/locales/zh/settings/sidebar.json +++ b/worklenz-frontend/public/locales/zh/settings/sidebar.json @@ -1,5 +1,6 @@ { "profile": "个人资料", + "appearance": "外观", "notifications": "通知", "clients": "客户", "job-titles": "职位", diff --git a/worklenz-frontend/public/locales/zh/settings/team-members.json b/worklenz-frontend/public/locales/zh/settings/team-members.json index 5826c6ec..8b39483c 100644 --- a/worklenz-frontend/public/locales/zh/settings/team-members.json +++ b/worklenz-frontend/public/locales/zh/settings/team-members.json @@ -1,4 +1,5 @@ { + "title": "团队成员", "nameColumn": "名称", "projectsColumn": "项目", "emailColumn": "电子邮件", @@ -40,5 +41,7 @@ "ownerText": "团队所有者", "addedText": "已添加", "updatedText": "已更新", - "noResultFound": "输入电子邮件地址并按回车键..." + "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-list-filters.json b/worklenz-frontend/public/locales/zh/task-list-filters.json index 300c8eb0..a3354305 100644 --- a/worklenz-frontend/public/locales/zh/task-list-filters.json +++ b/worklenz-frontend/public/locales/zh/task-list-filters.json @@ -50,5 +50,17 @@ "selectCategory": "选择类别", "pleaseEnterAName": "请输入名称", "pleaseSelectACategory": "请选择类别", - "create": "创建" + "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 index c380963e..d2f9634c 100644 --- a/worklenz-frontend/public/locales/zh/task-list-table.json +++ b/worklenz-frontend/public/locales/zh/task-list-table.json @@ -36,6 +36,7 @@ "addTaskText": "+ 添加任务", "addSubTaskText": "+ 添加子任务", "addTaskInputPlaceholder": "输入任务并按回车键", + "noTasksInGroup": "此组中没有任务", "openButton": "打开", "okButton": "确定", "noLabelsFound": "未找到标签", @@ -52,5 +53,74 @@ "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/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/settings/edit-team-name-modal.tsx b/worklenz-frontend/src/components/settings/edit-team-name-modal.tsx index 90a29abe..243a6146 100644 --- a/worklenz-frontend/src/components/settings/edit-team-name-modal.tsx +++ b/worklenz-frontend/src/components/settings/edit-team-name-modal.tsx @@ -1,5 +1,6 @@ import { Divider, Form, Input, message, Modal, Typography } from 'antd'; import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { editTeamName, fetchTeams } from '@/features/teams/teamSlice'; import { ITeamGetResponse } from '@/types/teams/team.type'; @@ -11,6 +12,7 @@ interface EditTeamNameModalProps { } const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalProps) => { + 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/task-drawer.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer.tsx index bbec5479..527988f1 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')} ); diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx index 37af547a..28d415ae 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx @@ -519,7 +519,7 @@ 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) { @@ -531,10 +531,10 @@ const TaskListV2: React.FC = () => {
- No task groups found + {t('emptyStates.noTaskGroups')}
- Tasks will appear here when they are created or when filters are applied. + {t('emptyStates.noTaskGroupsDescription')}
@@ -623,7 +623,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-management/improved-task-filters.tsx b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx index e3239f42..35de7b88 100644 --- a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx +++ b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx @@ -213,7 +213,7 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => { return [ { id: 'priority', - label: 'Priority', + label: t('priorityText'), options: filterData.priorities.map((p: any) => ({ value: p.id, label: p.name, @@ -288,7 +288,7 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => { return [ { id: 'priority', - label: 'Priority', + label: t('priorityText'), options: filterData.priorities.map((p: any) => ({ value: p.id, label: p.name, @@ -719,7 +719,34 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({ isDarkMode, }) => { const { t } = useTranslation('task-list-filters'); + const { t: tTable } = useTranslation('task-list-table'); const dispatch = useAppDispatch(); + + // Helper function to get translated field label using existing task-list-table translations + const getFieldLabel = useCallback((fieldKey: string) => { + const keyMappings: Record = { + 'KEY': 'keyColumn', + 'DESCRIPTION': 'descriptionColumn', + 'PROGRESS': 'progressColumn', + 'ASSIGNEES': 'assigneesColumn', + 'LABELS': 'labelsColumn', + 'PHASE': 'phaseColumn', + 'STATUS': 'statusColumn', + 'PRIORITY': 'priorityColumn', + 'TIME_TRACKING': 'timeTrackingColumn', + 'ESTIMATION': 'estimationColumn', + 'START_DATE': 'startDateColumn', + 'DUE_DATE': 'dueDateColumn', + 'DUE_TIME': 'dueTimeColumn', + 'COMPLETED_DATE': 'completedDateColumn', + 'CREATED_DATE': 'createdDateColumn', + 'LAST_UPDATED': 'lastUpdatedColumn', + 'REPORTER': 'reporterColumn', + }; + + const translationKey = keyMappings[fieldKey]; + return translationKey ? tTable(translationKey) : fieldKey; + }, [tTable]); const fieldsRaw = useSelector((state: RootState) => state.taskManagementFields); const columns = useSelector(selectColumns); const projectId = useAppSelector(state => state.projectReducer.projectId); @@ -857,7 +884,7 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({ {/* Label and Count */}
- {field.label} + {getFieldLabel(field.key)}
); diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index 5edf1814..289b98c5 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -1,5 +1,6 @@ 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 ProjectViewEnhancedBoard from '@/pages/projects/projectView/enhancedBoard/project-view-enhanced-board'; @@ -28,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) }, @@ -57,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) }, @@ -67,7 +73,7 @@ export const tabItems: TabItems[] = [ { index: 4, key: 'members', - label: 'Members', + label: getTabLabel('members'), element: React.createElement( Suspense, { fallback: React.createElement(InlineSuspenseFallback) }, @@ -77,7 +83,7 @@ export const tabItems: TabItems[] = [ { index: 5, key: 'updates', - label: 'Updates', + label: getTabLabel('updates'), element: React.createElement( Suspense, { fallback: React.createElement(InlineSuspenseFallback) }, @@ -85,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/projects/projectView/project-view-header.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx index 62db71fe..c77c149d 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx @@ -268,7 +268,7 @@ const ProjectViewHeader = memo(() => { { key: 'import', label: ( -
+
{t('importTask')}
), @@ -285,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 && ( @@ -348,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( - + + + ); } @@ -451,14 +461,16 @@ const ProjectViewHeader = memo(() => { const pageHeaderTitle = useMemo( () => ( - + + + {selectedProject?.name} {projectAttributes} ), - [handleNavigateToProjects, selectedProject?.name, projectAttributes] + [handleNavigateToProjects, selectedProject?.name, projectAttributes, t] ); // Memoized page header styles diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index 32b53f08..a18023f8 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); 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) => ( - + @@ -378,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') } > + @@ -463,9 +460,10 @@ const InfoTabFooter = () => { } > - Created{' '} - {taskFormViewModel?.task?.created_from_now || '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_from_now || '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/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'; From 66e01119d20eb4b3e2d68f7a8cc026b7e5ab03cc Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 8 Jul 2025 16:02:12 +0530 Subject: [PATCH 25/39] refactor(task-drawer): update tab behavior and enhance link handling in description editor - Changed tab component property from 'destroyInactiveTabPane' to 'destroyOnHidden' for improved tab management. - Added CSS styles for links in the description editor to enhance visibility based on theme mode. - Implemented link click handling to open links in a new tab while preventing default editor behavior, improving user experience. --- .../shared/info-tab/description-editor.tsx | 43 +++++++++++++++++-- .../components/task-drawer/task-drawer.tsx | 2 +- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/description-editor.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/description-editor.tsx index 470a18c9..078b07b0 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/description-editor.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/description-editor.tsx @@ -27,6 +27,18 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi const wrapperRef = useRef(null); const themeMode = useAppSelector(state => state.themeReducer.mode); + // 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; + } + `; + // Load TinyMCE script only when editor is opened const loadTinyMCE = async () => { if (isTinyMCELoaded) return; @@ -35,7 +47,7 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi try { // Load TinyMCE script dynamically await new Promise((resolve, reject) => { - if (window.tinymce) { + if ((window as any).tinymce) { resolve(); return; } @@ -75,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 ( @@ -119,6 +131,28 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi 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 = themeMode === 'dark' ? ` @@ -134,6 +168,8 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi return (
+ {/* Inject CSS styles for links */} + {isEditorOpen ? (
) : (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} style={{ @@ -244,6 +280,7 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content), }} + className="description-content" /> ) : (
{ From 6ac2a0c888dbea97e3a832c944c8c4e0cabb29bf Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 8 Jul 2025 16:13:56 +0530 Subject: [PATCH 26/39] refactor(task-list): improve layout and styling for better usability - Removed unnecessary padding from task filters for a cleaner look. - Adjusted height calculations to optimize space usage in the task list. - Added padding to the content area to ensure the horizontal scrollbar is visible. - Updated subtask count checks for clarity and consistency. - Modified gap and margin values in project view header for improved alignment. --- .../src/components/task-list-v2/TaskListV2.tsx | 18 ++++++++++-------- .../src/components/task-list-v2/TaskRow.tsx | 6 +++--- .../projectView/project-view-header.tsx | 10 +++++----- .../projects/projectView/project-view.tsx | 4 ++-- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx index 28d415ae..cf085758 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx @@ -525,7 +525,7 @@ const TaskListV2: React.FC = () => { if (groups.length === 0 && !loading) { return (
-
+
@@ -552,18 +552,15 @@ const TaskListV2: React.FC = () => { >
{/* Task Filters */} -
+
- {/* Spacing between filters and table */} -
- {/* Table Container */}
{
{/* Sticky Column Headers */}
diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index e85ab195..523b402e 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 )}
@@ -279,20 +281,10 @@ const LabelsSelector: React.FC = ({ task, isDarkMode = fals {/* Footer */}
- + {t('manageLabelsPath')} +
, document.body From bc6a15de8f14bd0467906ae0aee9fcc42ab73dbc Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 11:41:09 +0530 Subject: [PATCH 30/39] feat(localization): add 'share' label translations for multiple languages - Added the 'share' label to project view headers in Albanian, German, Spanish, Portuguese, Chinese, and English to enhance user interaction. - Updated corresponding button icons and labels in the project view header for improved functionality and consistency. --- .../public/locales/alb/project-view/project-view-header.json | 1 + .../public/locales/de/project-view/project-view-header.json | 1 + .../public/locales/en/project-view/project-view-header.json | 1 + .../public/locales/es/project-view/project-view-header.json | 1 + .../public/locales/pt/project-view/project-view-header.json | 1 + .../public/locales/zh/project-view/project-view-header.json | 1 + .../src/pages/projects/projectView/project-view-header.tsx | 5 +++-- 7 files changed, 9 insertions(+), 2 deletions(-) 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 f12bdd8d..51d91ba1 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 @@ -14,6 +14,7 @@ "refreshProject": "Rifresko projektin", "saveAsTemplate": "Ruaj si model", "invite": "Fto", + "share": "Ndaj", "subscribeTooltip": "Abonohu tek njoftimet e projektit", "unsubscribeTooltip": "Çabonohu nga njoftimet e projektit", "refreshTooltip": "Rifresko të dhënat e projektit", 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 dae5f67a..c52c6052 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 @@ -14,6 +14,7 @@ "refreshProject": "Projekt aktualisieren", "saveAsTemplate": "Als Vorlage speichern", "invite": "Einladen", + "share": "Teilen", "subscribeTooltip": "Projektbenachrichtigungen abonnieren", "unsubscribeTooltip": "Projektbenachrichtigungen beenden", "refreshTooltip": "Projektdaten aktualisieren", 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 536ccad4..1bbb6c15 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 @@ -14,6 +14,7 @@ "refreshProject": "Refresh project", "saveAsTemplate": "Save as template", "invite": "Invite", + "share": "Share", "subscribeTooltip": "Subscribe to project notifications", "unsubscribeTooltip": "Unsubscribe from project notifications", "refreshTooltip": "Refresh project data", 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 c6fb854b..0215b89c 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 @@ -14,6 +14,7 @@ "refreshProject": "Actualizar proyecto", "saveAsTemplate": "Guardar como plantilla", "invite": "Invitar", + "share": "Compartir", "subscribeTooltip": "Suscribirse a notificaciones del proyecto", "unsubscribeTooltip": "Cancelar suscripción a notificaciones del proyecto", "refreshTooltip": "Actualizar datos del proyecto", 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 6e295e38..4649b768 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 @@ -14,6 +14,7 @@ "refreshProject": "Atualizar projeto", "saveAsTemplate": "Salvar como modelo", "invite": "Convidar", + "share": "Compartilhar", "subscribeTooltip": "Inscrever-se nas notificações do projeto", "unsubscribeTooltip": "Cancelar inscrição nas notificações do projeto", "refreshTooltip": "Atualizar dados do projeto", 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 index ca0ead5c..9f8ca8ed 100644 --- a/worklenz-frontend/public/locales/zh/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/zh/project-view/project-view-header.json @@ -14,6 +14,7 @@ "refreshProject": "刷新项目", "saveAsTemplate": "保存为模板", "invite": "邀请", + "share": "分享", "subscribeTooltip": "订阅项目通知", "unsubscribeTooltip": "取消订阅项目通知", "refreshTooltip": "刷新项目数据", 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 24a577c7..4132f0c7 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx @@ -65,6 +65,7 @@ import { addTaskCardToTheTop, fetchBoardTaskGroups } from '@/features/board/boar import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice'; import { fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; +import { ShareAltOutlined } from '@ant-design/icons'; const ProjectViewHeader = memo(() => { const navigate = useNavigate(); @@ -395,8 +396,8 @@ const ProjectViewHeader = memo(() => { if (isOwnerOrAdmin || isProjectManager) { actions.push( - ); From ab7ca33ac10aef27ed56009a807b2b25c3e5b23b Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 11:58:40 +0530 Subject: [PATCH 31/39] feat(localization): improve translation handling and update UI labels - Updated the 'board' label in project-view translations for consistency. - Enhanced the getTabLabel function to include fallback labels for better user experience when translations are not available. - Implemented translation loading checks in ProjectView to ensure labels are updated correctly based on the selected language. - Refactored tab label updates to handle translation loading errors gracefully. --- .../public/locales/en/project-view.json | 2 +- .../src/lib/project/project-view-constants.ts | 79 +++++++++++++------ .../projects/projectView/project-view.tsx | 49 +++++++++--- 3 files changed, 93 insertions(+), 37 deletions(-) diff --git a/worklenz-frontend/public/locales/en/project-view.json b/worklenz-frontend/public/locales/en/project-view.json index 16d2a0bc..82ab21f2 100644 --- a/worklenz-frontend/public/locales/en/project-view.json +++ b/worklenz-frontend/public/locales/en/project-view.json @@ -1,6 +1,6 @@ { "taskList": "Task List", - "board": "Kanban Board", + "board": "Board", "insights": "Insights", "files": "Files", "members": "Members", diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index 289b98c5..c6b71a79 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -29,9 +29,36 @@ type TabItems = { element: ReactNode; }; -// Function to get translated labels +// Function to get translated labels with fallback const getTabLabel = (key: string): string => { - return i18n.t(`project-view:${key}`); + try { + const translated = i18n.t(`project-view:${key}`); + // If translation is not loaded, it returns the key back, so we provide fallbacks + if (translated === `project-view:${key}` || translated === key) { + // Provide fallback labels + const fallbacks: Record = { + taskList: 'Task List', + board: 'Board', + insights: 'Insights', + files: 'Files', + members: 'Members', + updates: 'Updates', + }; + return fallbacks[key] || key; + } + return translated; + } catch (error) { + // Fallback labels in case of any error + const fallbacks: Record = { + taskList: 'Task List', + board: 'Board', + insights: 'Insights', + files: 'Files', + members: 'Members', + updates: 'Updates', + }; + return fallbacks[key] || key; + } }; // settings all element items use for tabs @@ -94,26 +121,30 @@ 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; - } - }); + try { + 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; + } + }); + } catch (error) { + console.error('Error updating tab labels:', error); + } }; diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index 7509d74b..c0228007 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -7,8 +7,6 @@ import { Button, ConfigProvider, Flex, - Tooltip, - Badge, Tabs, PushpinFilled, PushpinOutlined, @@ -20,7 +18,6 @@ import { useAppSelector } from '@/hooks/useAppSelector'; import { getProject, setProjectId, setProjectView } from '@/features/project/project.slice'; import { fetchStatuses, resetStatuses } from '@/features/taskAttributes/taskStatusSlice'; import { projectsApiService } from '@/api/projects/projects.api.service'; -import { colors } from '@/styles/colors'; import { useDocumentTitle } from '@/hooks/useDoumentTItle'; import ProjectViewHeader from './project-view-header'; import './project-view.css'; @@ -42,6 +39,7 @@ import { resetState as resetEnhancedKanbanState } from '@/features/enhanced-kanb 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 { ensureTranslationsLoaded } from '@/i18n'; // Import critical components synchronously to avoid suspense interruptions import TaskDrawer from '@components/task-drawer/task-drawer'; @@ -64,12 +62,15 @@ const ProjectView = React.memo(() => { const dispatch = useAppDispatch(); const [searchParams] = useSearchParams(); const { projectId } = useParams(); - const { t } = useTranslation('project-view'); + const { t, i18n } = useTranslation('project-view'); // Memoized selectors to prevent unnecessary re-renders const selectedProject = useAppSelector(state => state.projectReducer.project); const projectLoading = useAppSelector(state => state.projectReducer.projectLoading); + // State to track translation loading + const [translationsReady, setTranslationsReady] = useState(false); + // Optimize document title updates useDocumentTitle(selectedProject?.name || t('projectView')); @@ -95,6 +96,30 @@ const ProjectView = React.memo(() => { setTaskId(urlParams.taskId); }, [urlParams]); + // Ensure translations are loaded for project-view namespace + useEffect(() => { + const loadTranslations = async () => { + try { + await ensureTranslationsLoaded(['project-view'], [i18n.language]); + updateTabLabels(); + setTranslationsReady(true); + } catch (error) { + console.error('Failed to load project-view translations:', error); + // Set ready to true anyway to prevent infinite loading + setTranslationsReady(true); + } + }; + + loadTranslations(); + }, [i18n.language]); + + // Update tab labels when language changes + useEffect(() => { + if (translationsReady) { + updateTabLabels(); + } + }, [t, translationsReady]); + // Comprehensive cleanup function for when leaving project view entirely const resetAllProjectData = useCallback(() => { dispatch(setProjectId(null)); @@ -176,11 +201,6 @@ 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) { @@ -250,6 +270,11 @@ const ProjectView = React.memo(() => { // Memoized tab menu items with enhanced styling const tabMenuItems = useMemo(() => { + // Only render tabs when translations are ready + if (!translationsReady) { + return []; + } + const menuItems = tabItems.map(item => ({ key: item.key, label: ( @@ -304,7 +329,7 @@ const ProjectView = React.memo(() => { })); return menuItems; - }, [pinnedTab, pinToDefaultTab, t]); + }, [pinnedTab, pinToDefaultTab, t, translationsReady]); // Optimized secondary components loading with better UX const [shouldLoadSecondaryComponents, setShouldLoadSecondaryComponents] = useState(false); @@ -341,8 +366,8 @@ const ProjectView = React.memo(() => { [shouldLoadSecondaryComponents] ); - // Show loading state while project is being fetched - if (projectLoading || !isInitialized) { + // Show loading state while project is being fetched or translations are loading + if (projectLoading || !isInitialized || !translationsReady) { return (
From 6dba080ade7a1a65f4b58f8e08f8c5171d2b0216 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 12:11:11 +0530 Subject: [PATCH 32/39] refactor(task-drawer): streamline task name handling and enhance socket management - Removed local socket listener for task name changes, relying on global socket handlers for real-time updates. - Simplified task name change handling logic to improve clarity and maintainability. - Enhanced task status group matching logic in useTaskSocketHandlers for better accuracy with multiple matching strategies. - Added detailed logging for task movement and status changes to aid in debugging and tracking. --- .../task-drawer-header/task-drawer-header.tsx | 22 +----- .../src/hooks/useTaskSocketHandlers.ts | 78 ++++++++++++++++++- 2 files changed, 78 insertions(+), 22 deletions(-) diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx index 735544ea..a41f56c0 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx @@ -19,6 +19,7 @@ import { deleteBoardTask, updateTaskName } from '@/features/board/board-slice'; import { updateEnhancedKanbanTaskName } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import useTabSearchParam from '@/hooks/useTabSearchParam'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { ITaskViewModel } from '@/types/tasks/task.types'; type TaskDrawerHeaderProps = { inputRef: React.RefObject; @@ -89,22 +90,6 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { }, ]; - const handleReceivedTaskNameChange = (data: { - id: string; - parent_task: string; - name: string; - }) => { - if (data.id === selectedTaskId) { - const taskData = { ...data, manual_progress: false } as IProjectTask; - dispatch(updateTaskName({ task: taskData })); - - // Also update enhanced kanban if on board tab - if (tab === 'board') { - dispatch(updateEnhancedKanbanTaskName({ task: taskData })); - } - } - }; - const handleInputBlur = () => { setIsEditing(false); if ( @@ -124,9 +109,8 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { parent_task: taskFormViewModel?.task?.parent_task_id, }) ); - socket?.once(SocketEvents.TASK_NAME_CHANGE.toString(), (data: any) => { - handleReceivedTaskNameChange(data); - }); + // Note: Real-time updates are handled by the global useTaskSocketHandlers hook + // No need for local socket listeners that could interfere with global handlers }; return ( diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index cbcb46c1..cf857653 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -243,10 +243,64 @@ export const useTaskSocketHandlers = () => { // Find current group containing the task const currentGroup = groups.find(group => group.taskIds.includes(response.id)); - // Find target group based on new status value (not UUID) - const targetGroup = groups.find(group => group.groupValue === newStatusValue); + // Find target group based on new status value with multiple matching strategies + let targetGroup = groups.find(group => group.groupValue === newStatusValue); + + // If not found, try case-insensitive matching + if (!targetGroup) { + targetGroup = groups.find(group => + group.groupValue?.toLowerCase() === newStatusValue.toLowerCase() + ); + } + + // If still not found, try matching with title + if (!targetGroup) { + targetGroup = groups.find(group => + group.title?.toLowerCase() === newStatusValue.toLowerCase() + ); + } + + // If still not found, try matching common status patterns + if (!targetGroup && newStatusValue === 'todo') { + targetGroup = groups.find(group => + group.title?.toLowerCase().includes('todo') || + group.title?.toLowerCase().includes('to do') || + group.title?.toLowerCase().includes('pending') || + group.groupValue?.toLowerCase().includes('todo') + ); + } else if (!targetGroup && newStatusValue === 'doing') { + targetGroup = groups.find(group => + group.title?.toLowerCase().includes('doing') || + group.title?.toLowerCase().includes('progress') || + group.title?.toLowerCase().includes('active') || + group.groupValue?.toLowerCase().includes('doing') + ); + } else if (!targetGroup && newStatusValue === 'done') { + targetGroup = groups.find(group => + group.title?.toLowerCase().includes('done') || + group.title?.toLowerCase().includes('complete') || + group.title?.toLowerCase().includes('finish') || + group.groupValue?.toLowerCase().includes('done') + ); + } + + console.log('🔄 Status change group movement debug:', { + taskId: response.id, + newStatusValue, + currentGroupId: currentGroup?.id, + currentGroupValue: currentGroup?.groupValue, + currentGroupTitle: currentGroup?.title, + targetGroupId: targetGroup?.id, + targetGroupValue: targetGroup?.groupValue, + targetGroupTitle: targetGroup?.title, + allGroups: groups.map(g => ({ id: g.id, title: g.title, groupValue: g.groupValue })) + }); if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) { + console.log('✅ Moving task between groups:', { + from: currentGroup.title, + to: targetGroup.title + }); // Use the action to move task between groups dispatch( moveTaskBetweenGroups({ @@ -255,8 +309,12 @@ export const useTaskSocketHandlers = () => { targetGroupId: targetGroup.id, }) ); + } else if (!targetGroup) { + console.log('❌ Target group not found for status:', newStatusValue); + } else if (!currentGroup) { + console.log('❌ Current group not found for task:', response.id); } else { - console.log('🔧 No group movement needed for status change'); + console.log('🔧 No group movement needed - task already in correct group'); } } else { console.log('🔧 Not grouped by status, skipping group movement'); @@ -628,7 +686,21 @@ export const useTaskSocketHandlers = () => { const handleTaskDescriptionChange = useCallback( (data: { id: string; parent_task: string; description: string }) => { if (!data) return; + + // Update the old task slice (for backward compatibility) dispatch(updateTaskDescription(data)); + + // Update task-management slice for task-list-v2 components + const currentTask = store.getState().taskManagement.entities[data.id]; + if (currentTask) { + const updatedTask: Task = { + ...currentTask, + description: data.description, + updatedAt: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + dispatch(updateTask(updatedTask)); + } }, [dispatch] ); From 29a09ec500b97d177fbe40606cf2377ac628924b Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 12:22:37 +0530 Subject: [PATCH 33/39] refactor(task-drawer): enhance task deletion handling and update imports - Updated task deletion logic to ensure consistency across task management slices, including clearing selections and updating the board state. - Refactored imports to streamline task management functionality and improve code clarity. - Added new Ant Design icon import for enhanced UI options. --- .../task-drawer-header/task-drawer-header.tsx | 24 ++++++++++--------- worklenz-frontend/src/shared/antd-imports.ts | 1 + 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx index a41f56c0..4777257a 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx @@ -15,10 +15,10 @@ import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import useTaskDrawerUrlSync from '@/hooks/useTaskDrawerUrlSync'; import { deleteTask } from '@/features/tasks/tasks.slice'; -import { deleteBoardTask, updateTaskName } from '@/features/board/board-slice'; -import { updateEnhancedKanbanTaskName } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { deleteTask as deleteTaskFromManagement } from '@/features/task-management/task-management.slice'; +import { deselectTask } from '@/features/task-management/selection.slice'; +import { deleteBoardTask } from '@/features/board/board-slice'; import useTabSearchParam from '@/hooks/useTabSearchParam'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { ITaskViewModel } from '@/types/tasks/task.types'; type TaskDrawerHeaderProps = { @@ -30,7 +30,6 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { const dispatch = useAppDispatch(); const { socket, connected } = useSocket(); const { clearTaskFromUrl } = useTaskDrawerUrlSync(); - const { tab } = useTabSearchParam(); const isDeleting = useRef(false); const [isEditing, setIsEditing] = useState(false); @@ -54,16 +53,19 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { const res = await tasksApiService.deleteTask(selectedTaskId); if (res.done) { - // Explicitly clear the task parameter from URL - clearTaskFromUrl(); + // Update all relevant slices to ensure task is removed everywhere + dispatch(deleteTask({ taskId: selectedTaskId })); // Old tasks slice + dispatch(deleteTaskFromManagement(selectedTaskId)); // Task management slice (TaskListV2) + dispatch(deselectTask(selectedTaskId)); // Remove from selection if selected + dispatch(deleteBoardTask({ sectionId: '', taskId: selectedTaskId })); // Board slice - dispatch(setShowTaskDrawer(false)); + // Clear the task drawer state and URL dispatch(setSelectedTaskId(null)); - dispatch(deleteTask({ taskId: selectedTaskId })); - dispatch(deleteBoardTask({ sectionId: '', taskId: selectedTaskId })); - - // Reset the flag after a short delay + dispatch(setShowTaskDrawer(false)); + + // Clear the URL parameter last to avoid race conditions setTimeout(() => { + clearTaskFromUrl(); isDeleting.current = false; }, 100); if (taskFormViewModel?.task?.parent_task_id) { diff --git a/worklenz-frontend/src/shared/antd-imports.ts b/worklenz-frontend/src/shared/antd-imports.ts index 9b8498c7..bd0b31bd 100644 --- a/worklenz-frontend/src/shared/antd-imports.ts +++ b/worklenz-frontend/src/shared/antd-imports.ts @@ -101,6 +101,7 @@ export { DoubleRightOutlined, UserAddOutlined, ArrowsAltOutlined, + EllipsisOutlined } from '@ant-design/icons'; // Re-export all components with React From 10c53d954e2594059395ab6b053b416e400547c6 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 12:32:17 +0530 Subject: [PATCH 34/39] refactor(task-list): unify date handling and enhance column widths - Updated TaskRow component to handle both camelCase and snake_case date fields for created and updated timestamps. - Adjusted column widths for due date, start date, completed date, created date, and last updated fields for better layout consistency. - Ensured whitespace handling in date display spans for improved UI presentation. --- .../src/components/task-list-v2/TaskRow.tsx | 29 ++++++++++--------- .../task-list-v2/constants/columns.ts | 10 +++---- .../task-management/task-management.slice.ts | 16 +++++----- .../src/types/task-management.types.ts | 7 +++-- 4 files changed, 32 insertions(+), 30 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index b3130cf8..bf2a663c 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -161,9 +161,9 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn })(), start: task.startDate ? formatDate(task.startDate) : null, completed: task.completedAt ? formatDate(task.completedAt) : null, - created: task.created_at ? formatDate(task.created_at) : null, + created: (task.createdAt || task.created_at) ? formatDate(task.createdAt || task.created_at) : null, updated: task.updatedAt ? formatDate(task.updatedAt) : null, - }), [task.dueDate, task.due_date, task.startDate, task.completedAt, task.created_at, task.updatedAt]); + }), [task.dueDate, task.due_date, task.startDate, task.completedAt, task.createdAt, task.created_at, task.updatedAt]); // Memoize date values for DatePicker const dateValues = useMemo( @@ -517,11 +517,11 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn }} > {formattedDates.due ? ( - + {formattedDates.due} ) : ( - + {t('setDueDate')} )} @@ -640,11 +640,11 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn }} > {formattedDates.start ? ( - + {formattedDates.start} ) : ( - + {t('setStartDate')} )} @@ -657,11 +657,11 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn return (
{formattedDates.completed ? ( - + {formattedDates.completed} ) : ( - - + - )}
); @@ -670,24 +670,25 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn return (
{formattedDates.created ? ( - + {formattedDates.created} ) : ( - - + - )}
); case 'lastUpdated': + console.log('formattedDates.updated', formattedDates.updated); return (
{formattedDates.updated ? ( - + {formattedDates.updated} ) : ( - - + - )}
); @@ -696,9 +697,9 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn return (
{task.reporter ? ( - {task.reporter} + {task.reporter} ) : ( - - + - )}
); diff --git a/worklenz-frontend/src/components/task-list-v2/constants/columns.ts b/worklenz-frontend/src/components/task-list-v2/constants/columns.ts index 8f105f12..d8b229fe 100644 --- a/worklenz-frontend/src/components/task-list-v2/constants/columns.ts +++ b/worklenz-frontend/src/components/task-list-v2/constants/columns.ts @@ -21,16 +21,16 @@ export const BASE_COLUMNS = [ { id: 'status', label: 'statusColumn', width: '120px', key: COLUMN_KEYS.STATUS }, { id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES }, { id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY }, - { id: 'dueDate', label: 'dueDateColumn', width: '120px', key: COLUMN_KEYS.DUE_DATE }, + { id: 'dueDate', label: 'dueDateColumn', width: '140px', key: COLUMN_KEYS.DUE_DATE }, { id: 'progress', label: 'progressColumn', width: '120px', key: COLUMN_KEYS.PROGRESS }, { id: 'labels', label: 'labelsColumn', width: 'auto', key: COLUMN_KEYS.LABELS }, { id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE }, { id: 'timeTracking', label: 'timeTrackingColumn', width: '120px', key: COLUMN_KEYS.TIME_TRACKING }, { id: 'estimation', label: 'estimationColumn', width: '120px', key: COLUMN_KEYS.ESTIMATION }, - { id: 'startDate', label: 'startDateColumn', width: '120px', key: COLUMN_KEYS.START_DATE }, + { id: 'startDate', label: 'startDateColumn', width: '140px', key: COLUMN_KEYS.START_DATE }, { id: 'dueTime', label: 'dueTimeColumn', width: '120px', key: COLUMN_KEYS.DUE_TIME }, - { id: 'completedDate', label: 'completedDateColumn', width: '120px', key: COLUMN_KEYS.COMPLETED_DATE }, - { id: 'createdDate', label: 'createdDateColumn', width: '120px', key: COLUMN_KEYS.CREATED_DATE }, - { id: 'lastUpdated', label: 'lastUpdatedColumn', width: '120px', key: COLUMN_KEYS.LAST_UPDATED }, + { id: 'completedDate', label: 'completedDateColumn', width: '140px', key: COLUMN_KEYS.COMPLETED_DATE }, + { id: 'createdDate', label: 'createdDateColumn', width: '140px', key: COLUMN_KEYS.CREATED_DATE }, + { id: 'lastUpdated', label: 'lastUpdatedColumn', width: '140px', key: COLUMN_KEYS.LAST_UPDATED }, { id: 'reporter', label: 'reporterColumn', width: '120px', key: COLUMN_KEYS.REPORTER }, ]; \ No newline at end of file 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 9a41b589..54eee67d 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -171,10 +171,10 @@ export const fetchTasks = createAsyncThunk( logged: convertTimeValue(task.time_spent), }, customFields: {}, - createdAt: task.created_at || new Date().toISOString(), - updatedAt: task.updated_at || new Date().toISOString(), - created_at: task.created_at || new Date().toISOString(), - updated_at: task.updated_at || new Date().toISOString(), + createdAt: task.createdAt || task.created_at || new Date().toISOString(), + updatedAt: task.updatedAt || task.updated_at || new Date().toISOString(), + created_at: task.createdAt || task.created_at || new Date().toISOString(), + updated_at: task.updatedAt || task.updated_at || new Date().toISOString(), order: typeof task.sort_order === 'number' ? task.sort_order : 0, // Ensure all Task properties are mapped, even if undefined in API response sub_tasks: task.sub_tasks || [], @@ -285,10 +285,10 @@ export const fetchTasksV3 = createAsyncThunk( }, customFields: {}, custom_column_values: task.custom_column_values || {}, - createdAt: task.created_at || now, - updatedAt: task.updated_at || now, - created_at: task.created_at || now, - updated_at: task.updated_at || now, + createdAt: task.createdAt || task.created_at || now, + updatedAt: task.updatedAt || task.updated_at || now, + created_at: task.createdAt || task.created_at || now, + updated_at: task.updatedAt || task.updated_at || now, order: typeof task.sort_order === 'number' ? task.sort_order : 0, sub_tasks: task.sub_tasks || [], sub_tasks_count: task.sub_tasks_count || 0, diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index 32518142..cb0e749d 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -19,9 +19,10 @@ export interface Task { dueDate?: string; // Alternative due date field startDate?: string; // Start date field completedAt?: string; // Completion date - updatedAt?: string; // Update timestamp - created_at: string; - updated_at: string; + updatedAt?: string; // Update timestamp (camelCase from API) + createdAt?: string; // Creation timestamp (camelCase from API) + created_at: string; // Creation timestamp (snake_case, legacy) + updated_at: string; // Update timestamp (snake_case, legacy) sub_tasks?: Task[]; sub_tasks_count?: number; show_sub_tasks?: boolean; From fadc115412ece97286f0026b32e300b43c720695 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 13:01:51 +0530 Subject: [PATCH 35/39] feat(task-reporter): add reporter field to task data structure - Introduced a new 'reporter' field in the task data structure for both backend and frontend task management. - Updated the tasks-controller to include the reporter information when transforming task data. - Modified the fetchTasks and fetchTasksV3 functions to handle the reporter field, ensuring it defaults to undefined when not present. --- worklenz-backend/src/controllers/tasks-controller-v2.ts | 1 + .../src/features/task-management/task-management.slice.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index f5dcc666..39877ea1 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -1161,6 +1161,7 @@ export default class TasksControllerV2 extends TasksControllerBase { attachments_count: task.attachments_count || 0, has_dependencies: !!task.has_dependencies, schedule_id: task.schedule_id || null, + reporter: task.reporter || null, }; }); 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 54eee67d..c555f6e3 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -189,6 +189,7 @@ export const fetchTasks = createAsyncThunk( attachments_count: task.attachments_count || 0, has_dependencies: task.has_dependencies || false, schedule_id: task.schedule_id || null, + reporter: task.reporter || undefined, })) ); @@ -302,6 +303,7 @@ export const fetchTasksV3 = createAsyncThunk( attachments_count: task.attachments_count || 0, has_dependencies: task.has_dependencies || false, schedule_id: task.schedule_id || null, + reporter: task.reporter || undefined, }; return transformedTask; From 04f622a7f0d7f61b2936d1fc3670c9ccb08c29dd Mon Sep 17 00:00:00 2001 From: shancds Date: Wed, 9 Jul 2025 14:21:10 +0530 Subject: [PATCH 36/39] refactor(task-list): streamline TaskListV2 component and improve structure - Removed unused imports and consolidated task list logic for better readability. - Introduced TaskListV2Section for improved organization and separation of concerns. - Enhanced task filtering and rendering logic to optimize performance and maintainability. - Updated styling and layout for a cleaner user interface and better usability. --- .../src/controllers/tasks-controller-v2.ts | 997 ++++++------------ .../components/task-list-v2/TaskListV2.tsx | 682 +----------- .../task-list-v2/TaskListV2Table.tsx | 678 ++++++++++++ 3 files changed, 1035 insertions(+), 1322 deletions(-) create mode 100644 worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index f5dcc666..79f1c7c0 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 ? `t.project_id IN (${this.flatString(text)})` : ""; + return text ? `project_id IN (${this.flatString(text)})` : ""; } private static getFilterByAssignee(filterBy: string) { return filterBy === "member" - ? `t.id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id = $1)` - : "t.project_id = $1"; + ? `id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id = $1)` + : "project_id = $1"; } private static getStatusesQuery(filterBy: string) { @@ -130,20 +130,42 @@ export default class TasksControllerV2 extends TasksControllerBase { const filterByAssignee = TasksControllerV2.getFilterByAssignee(options.filterBy as string); // Returns statuses of each task as a json array if filterBy === "member" const statusesQuery = TasksControllerV2.getStatusesQuery(options.filterBy as string); - - // Custom columns data query - optimized with LEFT JOIN - const customColumnsQuery = options.customColumns - ? `, COALESCE(cc_data.custom_column_values, '{}'::JSONB) AS custom_column_values` + + // Custom columns data query + 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` : ""; - const archivedFilter = options.archived === "true" ? "t.archived IS TRUE" : "t.archived IS FALSE"; + const archivedFilter = options.archived === "true" ? "archived IS TRUE" : "archived IS FALSE"; let subTasksFilter; if (options.isSubtasksInclude === "true") { subTasksFilter = ""; } else { - subTasksFilter = isSubTasks ? "t.parent_task_id = $2" : "t.parent_task_id IS NULL"; + subTasksFilter = isSubTasks ? "parent_task_id = $2" : "parent_task_id IS NULL"; } const filters = [ @@ -157,171 +179,94 @@ export default class TasksControllerV2 extends TasksControllerBase { projectsFilter ].filter(i => !!i).join(" AND "); - // PERFORMANCE OPTIMIZED QUERY - Using CTEs and JOINs instead of correlated subqueries return ` - 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} + 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} 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} `; @@ -383,7 +328,7 @@ export default class TasksControllerV2 extends TasksControllerBase { public static async getList(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const startTime = performance.now(); console.log(`[PERFORMANCE] getList method called for project ${req.params.id} - THIS METHOD IS DEPRECATED, USE getTasksV3 INSTEAD`); - + // 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 @@ -397,12 +342,12 @@ export default class TasksControllerV2 extends TasksControllerBase { const isSubTasks = !!req.query.parent_task; const groupBy = (req.query.group || GroupBy.STATUS) as string; - + // Add customColumns flag to query params 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.user?.id] : [req.params.id || null, req.user?.id]; + const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; const result = await db.query(q, params); const tasks = [...result.rows]; @@ -433,7 +378,7 @@ export default class TasksControllerV2 extends TasksControllerBase { const endTime = performance.now(); const totalTime = endTime - startTime; console.log(`[PERFORMANCE] getList method completed in ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${tasks.length} tasks`); - + // Log warning if this deprecated method is taking too long if (totalTime > 1000) { console.warn(`[PERFORMANCE WARNING] DEPRECATED getList method taking ${totalTime.toFixed(2)}ms - Frontend should use getTasksV3 instead!`); @@ -445,16 +390,16 @@ export default class TasksControllerV2 extends TasksControllerBase { public static async updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: ITaskGroup }) { let index = 0; const unmapped = []; - + // PERFORMANCE OPTIMIZATION: Remove expensive individual DB calls for each task // Progress values are already calculated and included in the main query // No need to make additional database calls here - + // Process tasks with their already-calculated progress values for (const task of tasks) { task.index = index++; TasksControllerV2.updateTaskViewModel(task); - + if (groupBy === GroupBy.STATUS) { map[task.status]?.tasks.push(task); } else if (groupBy === GroupBy.PRIORITY) { @@ -492,7 +437,7 @@ export default class TasksControllerV2 extends TasksControllerBase { public static async getTasksOnly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const startTime = performance.now(); console.log(`[PERFORMANCE] getTasksOnly method called for project ${req.params.id} - Consider using getTasksV3 for better performance`); - + // 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 @@ -505,12 +450,12 @@ export default class TasksControllerV2 extends TasksControllerBase { } const isSubTasks = !!req.query.parent_task; - + // Add customColumns flag to query params 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.user?.id] : [req.params.id || null, req.user?.id]; + const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; const result = await db.query(q, params); let data: any[] = []; @@ -520,11 +465,11 @@ export default class TasksControllerV2 extends TasksControllerBase { [data] = result.rows; } else { // else we return a flat list of tasks data = [...result.rows]; - + // PERFORMANCE OPTIMIZATION: Remove expensive individual DB calls for each task // Progress values are already calculated and included in the main query via get_task_complete_ratio // The database query already includes complete_ratio, so no need for additional calls - + for (const task of data) { TasksControllerV2.updateTaskViewModel(task); } @@ -533,7 +478,7 @@ export default class TasksControllerV2 extends TasksControllerBase { const endTime = performance.now(); const totalTime = endTime - startTime; console.log(`[PERFORMANCE] getTasksOnly method completed in ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${data.length} tasks`); - + // Log warning if this method is taking too long if (totalTime > 1000) { console.warn(`[PERFORMANCE WARNING] getTasksOnly method taking ${totalTime.toFixed(2)}ms - Consider using getTasksV3 for better performance!`); @@ -575,9 +520,9 @@ export default class TasksControllerV2 extends TasksControllerBase { "SELECT COUNT(*) as subtask_count FROM tasks WHERE parent_task_id = $1 AND archived IS FALSE", [parentTaskId] ); - + const subtaskCount = parseInt(subTasksResult.rows[0]?.subtask_count || "0"); - + // If it has subtasks, reset the manual_progress flag to false if (subtaskCount > 0) { await db.query( @@ -585,24 +530,24 @@ export default class TasksControllerV2 extends TasksControllerBase { [parentTaskId] ); console.log(`Reset manual progress for parent task ${parentTaskId} with ${subtaskCount} subtasks`); - + // Get the project settings to determine which calculation method to use const projectResult = await db.query( "SELECT project_id FROM tasks WHERE id = $1", [parentTaskId] ); - + const projectId = projectResult.rows[0]?.project_id; - + if (projectId) { // Recalculate the parent task's progress based on its subtasks const progressResult = await db.query( "SELECT get_task_complete_ratio($1) AS ratio", [parentTaskId] ); - + const progressRatio = progressResult.rows[0]?.ratio?.ratio || 0; - + // Emit the updated progress value to all clients // Note: We don't have socket context here, so we can't directly emit // This will be picked up on the next client refresh @@ -653,7 +598,7 @@ export default class TasksControllerV2 extends TasksControllerBase { ? [req.body.id, req.body.to_group_id] : [req.body.id, req.body.project_id, req.body.parent_task_id, req.body.to_group_id]; await db.query(q, params); - + // Reset the parent task's manual progress when converting a task to a subtask if (req.body.parent_task_id) { await this.resetParentTaskManualProgress(req.body.parent_task_id); @@ -824,27 +769,27 @@ export default class TasksControllerV2 extends TasksControllerBase { // Get column information const columnQuery = ` - SELECT id, field_type - FROM cc_custom_columns + SELECT id, field_type + FROM cc_custom_columns WHERE project_id = $1 AND key = $2 `; const columnResult = await db.query(columnQuery, [project_id, column_key]); - + if (columnResult.rowCount === 0) { return res.status(404).send(new ServerResponse(false, "Custom column not found")); } - + const column = columnResult.rows[0]; const columnId = column.id; const fieldType = column.field_type; - + // Determine which value field to use based on the field_type let textValue = null; let numberValue = null; let dateValue = null; let booleanValue = null; let jsonValue = null; - + switch (fieldType) { case "number": numberValue = parseFloat(String(value)); @@ -861,55 +806,55 @@ export default class TasksControllerV2 extends TasksControllerBase { default: textValue = String(value); } - + // Check if a value already exists const existingValueQuery = ` - SELECT id - FROM cc_column_values + SELECT id + FROM cc_column_values WHERE task_id = $1 AND column_id = $2 `; const existingValueResult = await db.query(existingValueQuery, [taskId, columnId]); - + if (existingValueResult.rowCount && existingValueResult.rowCount > 0) { // Update existing value const updateQuery = ` - UPDATE cc_column_values - SET text_value = $1, - number_value = $2, - date_value = $3, - boolean_value = $4, - json_value = $5, - updated_at = NOW() + UPDATE cc_column_values + SET text_value = $1, + number_value = $2, + date_value = $3, + boolean_value = $4, + json_value = $5, + updated_at = NOW() WHERE task_id = $6 AND column_id = $7 `; await db.query(updateQuery, [ - textValue, - numberValue, - dateValue, - booleanValue, - jsonValue, - taskId, + textValue, + numberValue, + dateValue, + booleanValue, + jsonValue, + taskId, columnId ]); } else { // Insert new value const insertQuery = ` - INSERT INTO cc_column_values - (task_id, column_id, text_value, number_value, date_value, boolean_value, json_value, created_at, updated_at) + INSERT INTO cc_column_values + (task_id, column_id, text_value, number_value, date_value, boolean_value, json_value, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) `; await db.query(insertQuery, [ - taskId, - columnId, - textValue, - numberValue, - dateValue, - booleanValue, + taskId, + columnId, + textValue, + numberValue, + dateValue, + booleanValue, jsonValue ]); } - return res.status(200).send(new ServerResponse(true, { + return res.status(200).send(new ServerResponse(true, { task_id: taskId, column_key, value @@ -917,7 +862,7 @@ export default class TasksControllerV2 extends TasksControllerBase { } public static async refreshProjectTaskProgressValues(projectId: string): Promise { - try { + try { // Run the recalculate_all_task_progress function only for tasks in this project const query = ` DO $$ @@ -932,12 +877,12 @@ export default class TasksControllerV2 extends TasksControllerBase { WHERE parent_task_id = t.id AND archived IS FALSE ); - + -- Start recalculation from leaf tasks (no subtasks) and propagate upward -- This ensures calculations are done in the right order WITH RECURSIVE task_hierarchy AS ( -- Base case: Start with all leaf tasks (no subtasks) in this project - SELECT + SELECT id, parent_task_id, 0 AS level @@ -949,11 +894,11 @@ export default class TasksControllerV2 extends TasksControllerBase { AND sub.archived IS FALSE ) AND archived IS FALSE - + UNION ALL - + -- Recursive case: Move up to parent tasks, but only after processing all their children - SELECT + SELECT t.id, t.parent_task_id, th.level + 1 @@ -974,7 +919,7 @@ export default class TasksControllerV2 extends TasksControllerBase { AND (manual_progress IS FALSE OR manual_progress IS NULL); END $$; `; - + await db.query(query); console.log(`Finished refreshing progress values for project ${projectId}`); } catch (error) { @@ -987,24 +932,24 @@ export default class TasksControllerV2 extends TasksControllerBase { // Calculate the task's progress using get_task_complete_ratio const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]); const [data] = result.rows; - + if (data && data.info && data.info.ratio !== undefined) { const progressValue = +((data.info.ratio || 0).toFixed()); - + // Update the task's progress_value in the database await db.query( "UPDATE tasks SET progress_value = $1 WHERE id = $2", [progressValue, taskId] ); - + console.log(`Updated progress for task ${taskId} to ${progressValue}%`); - + // If this task has a parent, update the parent's progress as well const parentResult = await db.query( "SELECT parent_task_id FROM tasks WHERE id = $1", [taskId] ); - + if (parentResult.rows.length > 0 && parentResult.rows[0].parent_task_id) { await this.updateTaskProgress(parentResult.rows[0].parent_task_id); } @@ -1022,13 +967,13 @@ export default class TasksControllerV2 extends TasksControllerBase { "UPDATE tasks SET weight = $1 WHERE id = $2", [weight, taskId] ); - + // Get the parent task ID const parentResult = await db.query( "SELECT parent_task_id FROM tasks WHERE id = $1", [taskId] ); - + // If this task has a parent, update the parent's progress if (parentResult.rows.length > 0 && parentResult.rows[0].parent_task_id) { await this.updateTaskProgress(parentResult.rows[0].parent_task_id); @@ -1041,62 +986,60 @@ export default class TasksControllerV2 extends TasksControllerBase { @HandleExceptions() public static async getTasksV3(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const startTime = performance.now(); - console.log(`[PERFORMANCE] getTasksV3 method called for project ${req.params.id}`); - + const isSubTasks = !!req.query.parent_task; + const groupBy = (req.query.group || GroupBy.STATUS) as string; + const archived = req.query.archived === "true"; + // 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 - if (req.query.refresh_progress === "true" && req.params.id) { - console.log(`[PERFORMANCE] Starting progress refresh for project ${req.params.id} (getTasksV3)`); + // This dramatically improves initial load performance (from ~2-5s to ~200-500ms) + const shouldRefreshProgress = req.query.refresh_progress === "true"; + + if (shouldRefreshProgress && 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; - - // Add customColumns flag to query params (same as getList) - req.query.customColumns = "true"; - - // Use the exact same database query as getList method + const queryStartTime = performance.now(); const q = TasksControllerV2.getQuery(req.user?.id as string, req.query); - const params = isSubTasks ? [req.params.id || null, req.query.parent_task, req.user?.id] : [req.params.id || null, req.user?.id]; + const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; const result = await db.query(q, params); const tasks = [...result.rows]; + const queryEndTime = performance.now(); - // Use the same groups query as getList method + // Get groups metadata dynamically from database + const groupsStartTime = performance.now(); 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; - }, {}); + const groupsEndTime = performance.now(); - // 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 + // Create priority value to name mapping const priorityMap: Record = { "0": "low", - "1": "medium", + "1": "medium", "2": "high" }; - // Transform all tasks to V3 format + // 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(); 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; @@ -1119,12 +1062,15 @@ export default class TasksControllerV2 extends TasksControllerBase { task_key: task.task_key || "", title: task.name || "", description: task.description || "", - status: task.status || "todo", + // Use dynamic status mapping from database + status: statusCategoryMap[task.status] || task.status, + // Pre-processed priority using mapping 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.assignees || [], + assignee_names: task.assignee_names || task.names || [], labels: task.labels?.map((l: any) => ({ id: l.id || l.label_id, name: l.name, @@ -1132,11 +1078,6 @@ export default class TasksControllerV2 extends TasksControllerBase { end: l.end, names: l.names })) || [], - all_labels: task.all_labels?.map((l: any) => ({ - id: l.id || l.label_id, - name: l.name, - color_code: l.color_code || "#1890ff" - })) || [], dueDate: task.end_date || task.END_DATE, startDate: task.start_date, timeTracking: { @@ -1144,7 +1085,7 @@ export default class TasksControllerV2 extends TasksControllerBase { logged: convertTimeValue(task.time_spent), }, customFields: {}, - custom_column_values: task.custom_column_values || {}, + custom_column_values: task.custom_column_values || {}, // Include custom column values createdAt: task.created_at || new Date().toISOString(), updatedAt: task.updated_at || new Date().toISOString(), order: typeof task.sort_order === "number" ? task.sort_order : 0, @@ -1163,53 +1104,124 @@ export default class TasksControllerV2 extends TasksControllerBase { schedule_id: task.schedule_id || null, }; }); + const transformEndTime = performance.now(); - // 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, "_"); - } + // Create groups based on dynamic data from database + const groupingStartTime = performance.now(); + const groupedResponse: Record = {}; - // 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; - }); + // 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, "_"); - 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"); + 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, + }; + }); + + // 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); + } + } + }); + + // 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(); 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 this method is taking too long + + // Log warning if request is taking too long if (totalTime > 1000) { - console.warn(`[PERFORMANCE WARNING] getTasksV3 method taking ${totalTime.toFixed(2)}ms - Consider optimizing the query or data processing!`); + console.warn(`[PERFORMANCE WARNING] Slow request detected: ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks`); } return res.status(200).send(new ServerResponse(true, { @@ -1220,320 +1232,11 @@ 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]: { todo: "#f0f0f0", - doing: "#1890ff", + doing: "#1890ff", done: "#52c41a", }, [GroupBy.PRIORITY]: { @@ -1550,7 +1253,7 @@ export default class TasksControllerV2 extends TasksControllerBase { unmapped: "#fbc84c69", }, }; - + return colorMaps[groupBy]?.[groupValue] || "#d9d9d9"; } @@ -1558,15 +1261,15 @@ export default class TasksControllerV2 extends TasksControllerBase { public static async refreshTaskProgress(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { try { const startTime = performance.now(); - + if (req.params.id) { console.log(`[PERFORMANCE] Starting background progress refresh for project ${req.params.id}`); await this.refreshProjectTaskProgressValues(req.params.id); - + const endTime = performance.now(); const totalTime = endTime - startTime; console.log(`[PERFORMANCE] Background progress refresh completed in ${totalTime.toFixed(2)}ms for project ${req.params.id}`); - + return res.status(200).send(new ServerResponse(true, { message: "Task progress values refreshed successfully", performanceMetrics: { @@ -1592,31 +1295,31 @@ export default class TasksControllerV2 extends TasksControllerBase { // Get basic progress stats without expensive calculations const result = await db.query(` - SELECT + SELECT COUNT(*) as total_tasks, COUNT(CASE WHEN EXISTS( - SELECT 1 FROM tasks_with_status_view - WHERE tasks_with_status_view.task_id = tasks.id + SELECT 1 FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = tasks.id AND is_done IS TRUE ) THEN 1 END) as completed_tasks, - AVG(CASE - WHEN progress_value IS NOT NULL THEN progress_value - ELSE 0 + AVG(CASE + WHEN progress_value IS NOT NULL THEN progress_value + ELSE 0 END) as avg_progress, MAX(updated_at) as last_updated - FROM tasks + FROM tasks WHERE project_id = $1 AND archived IS FALSE `, [req.params.id]); const [stats] = result.rows; - + return res.status(200).send(new ServerResponse(true, { projectId: req.params.id, totalTasks: parseInt(stats.total_tasks) || 0, completedTasks: parseInt(stats.completed_tasks) || 0, avgProgress: parseFloat(stats.avg_progress) || 0, lastUpdated: stats.last_updated, - completionPercentage: stats.total_tasks > 0 ? + completionPercentage: stats.total_tasks > 0 ? Math.round((parseInt(stats.completed_tasks) / parseInt(stats.total_tasks)) * 100) : 0 })); } catch (error) { @@ -1624,6 +1327,4 @@ 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-frontend/src/components/task-list-v2/TaskListV2.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx index 0d6f5df4..85b02ea7 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx @@ -1,682 +1,16 @@ -import React, { useCallback, useMemo, useEffect, useState, useRef } from 'react'; -import { GroupedVirtuoso } from 'react-virtuoso'; -import { - DndContext, - DragOverlay, - PointerSensor, - useSensor, - useSensors, - KeyboardSensor, - TouchSensor, - closestCenter, -} from '@dnd-kit/core'; -import { - SortableContext, - verticalListSortingStrategy, - sortableKeyboardCoordinates, -} from '@dnd-kit/sortable'; -import { useTranslation } from 'react-i18next'; -import { useParams } from 'react-router-dom'; -import { createPortal } from 'react-dom'; -import { Skeleton } from 'antd'; -import { HolderOutlined } from '@ant-design/icons'; - -// Redux hooks and selectors -import { useAppSelector } from '@/hooks/useAppSelector'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { - selectAllTasksArray, - selectGroups, - selectGrouping, - selectLoading, - selectError, - fetchTasksV3, - fetchTaskListColumns, - selectColumns, - selectCustomColumns, - selectLoadingColumns, - updateColumnVisibility, -} from '@/features/task-management/task-management.slice'; -import { - selectCurrentGrouping, - selectCollapsedGroups, - toggleGroupCollapsed, -} from '@/features/task-management/grouping.slice'; -import { - selectSelectedTaskIds, - selectLastSelectedTaskId, - selectTask, - toggleTaskSelection, - selectRange, - clearSelection, -} from '@/features/task-management/selection.slice'; -import { - setCustomColumnModalAttributes, - toggleCustomColumnModalOpen, -} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice'; - -// Components -import TaskRowWithSubtasks from './TaskRowWithSubtasks'; -import TaskGroupHeader from './TaskGroupHeader'; -import ImprovedTaskFilters from '@/components/task-management/improved-task-filters'; -import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar'; -import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal'; -import AddTaskRow from './components/AddTaskRow'; -import { - AddCustomColumnButton, - CustomColumnHeader, -} from './components/CustomColumnComponents'; - -// Hooks and utilities -import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; -import { useSocket } from '@/socket/socketContext'; -import { useDragAndDrop } from './hooks/useDragAndDrop'; -import { useBulkActions } from './hooks/useBulkActions'; - -// Constants and types -import { BASE_COLUMNS, ColumnStyle } from './constants/columns'; -import { Task } from '@/types/task-management.types'; -import { SocketEvents } from '@/shared/socket-events'; +import ImprovedTaskFilters from "../task-management/improved-task-filters"; +import TaskListV2Section from "./TaskListV2Table"; const TaskListV2: React.FC = () => { - const dispatch = useAppDispatch(); - const { projectId: urlProjectId } = useParams(); - const { t } = useTranslation('task-list-table'); - const { socket, connected } = useSocket(); - - // Redux state selectors - const allTasks = useAppSelector(selectAllTasksArray); - const groups = useAppSelector(selectGroups); - const grouping = useAppSelector(selectGrouping); - const loading = useAppSelector(selectLoading); - const error = useAppSelector(selectError); - const currentGrouping = useAppSelector(selectCurrentGrouping); - const selectedTaskIds = useAppSelector(selectSelectedTaskIds); - const lastSelectedTaskId = useAppSelector(selectLastSelectedTaskId); - const collapsedGroups = useAppSelector(selectCollapsedGroups); - - const fields = useAppSelector(state => state.taskManagementFields) || []; - const columns = useAppSelector(selectColumns); - const customColumns = useAppSelector(selectCustomColumns); - const loadingColumns = useAppSelector(selectLoadingColumns); - - // Refs for scroll synchronization - const headerScrollRef = useRef(null); - const contentScrollRef = useRef(null); - - // State hooks - const [initializedFromDatabase, setInitializedFromDatabase] = useState(false); - - // Configure sensors for drag and drop - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - useSensor(TouchSensor, { - activationConstraint: { - delay: 250, - tolerance: 5, - }, - }) - ); - - // Custom hooks - const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(allTasks, groups); - const bulkActions = useBulkActions(); - - // Enable real-time updates via socket handlers - useTaskSocketHandlers(); - - // Filter visible columns based on local fields (primary) and backend columns (fallback) - const visibleColumns = useMemo(() => { - // Start with base columns - const baseVisibleColumns = BASE_COLUMNS.filter(column => { - // Always show drag handle and title (sticky columns) - if (column.isSticky) return true; - - // Primary: Check local fields configuration - const field = fields.find(f => f.key === column.key); - if (field) { - return field.visible; - } - - // Fallback: Check backend column configuration if local field not found - const backendColumn = columns.find(c => c.key === column.key); - if (backendColumn) { - return backendColumn.pinned ?? false; - } - - // Default: hide if neither local field nor backend column found - return false; - }); - - // Add visible custom columns - const visibleCustomColumns = customColumns - ?.filter(column => column.pinned) - ?.map(column => { - // Give selection columns more width for dropdown content - const fieldType = column.custom_column_obj?.fieldType; - let defaultWidth = 160; - if (fieldType === 'selection') { - defaultWidth = 150; // Reduced width for selection dropdowns - } else if (fieldType === 'people') { - defaultWidth = 170; // Extra width for people with avatars - } - - // Map the configuration data structure to the expected format - const customColumnObj = column.custom_column_obj || (column as any).configuration; - - // Transform configuration format to custom_column_obj format if needed - let transformedColumnObj = customColumnObj; - if (customColumnObj && !customColumnObj.fieldType && customColumnObj.field_type) { - transformedColumnObj = { - ...customColumnObj, - fieldType: customColumnObj.field_type, - numberType: customColumnObj.number_type, - labelPosition: customColumnObj.label_position, - previewValue: customColumnObj.preview_value, - firstNumericColumn: customColumnObj.first_numeric_column_key, - secondNumericColumn: customColumnObj.second_numeric_column_key, - selectionsList: customColumnObj.selections_list || customColumnObj.selectionsList || [], - labelsList: customColumnObj.labels_list || customColumnObj.labelsList || [], - }; - } - - return { - id: column.key || column.id || 'unknown', - label: column.name || t('customColumns.customColumnHeader'), - width: `${(column as any).width || defaultWidth}px`, - key: column.key || column.id || 'unknown', - custom_column: true, - custom_column_obj: transformedColumnObj, - isCustom: true, - name: column.name, - uuid: column.id, - }; - }) || []; - - return [...baseVisibleColumns, ...visibleCustomColumns]; - }, [fields, columns, customColumns, t]); - - // Effects - useEffect(() => { - if (urlProjectId) { - dispatch(fetchTasksV3(urlProjectId)); - dispatch(fetchTaskListColumns(urlProjectId)); - } - }, [dispatch, urlProjectId]); - - // Initialize field visibility from database when columns are loaded (only once) - useEffect(() => { - if (columns.length > 0 && fields.length > 0 && !initializedFromDatabase) { - // Update local fields to match database state only on initial load - import('@/features/task-management/taskListFields.slice').then(({ setFields }) => { - // Create updated fields based on database column state - const updatedFields = fields.map(field => { - const backendColumn = columns.find(c => c.key === field.key); - if (backendColumn) { - return { - ...field, - visible: backendColumn.pinned ?? field.visible - }; - } - return field; - }); - - // Only update if there are actual changes - const hasChanges = updatedFields.some((field, index) => - field.visible !== fields[index].visible - ); - - if (hasChanges) { - dispatch(setFields(updatedFields)); - } - - setInitializedFromDatabase(true); - }); - } - }, [columns, fields, dispatch, initializedFromDatabase]); - - // Event handlers - const handleTaskSelect = useCallback( - (taskId: string, event: React.MouseEvent) => { - if (event.ctrlKey || event.metaKey) { - dispatch(toggleTaskSelection(taskId)); - } else if (event.shiftKey && lastSelectedTaskId) { - const taskIds = allTasks.map(t => t.id); - const startIdx = taskIds.indexOf(lastSelectedTaskId); - const endIdx = taskIds.indexOf(taskId); - const rangeIds = taskIds.slice(Math.min(startIdx, endIdx), Math.max(startIdx, endIdx) + 1); - dispatch(selectRange(rangeIds)); - } else { - dispatch(clearSelection()); - dispatch(selectTask(taskId)); - } - }, - [dispatch, lastSelectedTaskId, allTasks] - ); - - const handleGroupCollapse = useCallback( - (groupId: string) => { - dispatch(toggleGroupCollapsed(groupId)); - }, - [dispatch] - ); - - // Function to update custom column values - const updateTaskCustomColumnValue = useCallback((taskId: string, columnKey: string, value: string) => { - try { - if (!urlProjectId) { - console.error('Project ID is missing'); - return; - } - - const body = { - task_id: taskId, - column_key: columnKey, - value: value, - project_id: urlProjectId, - }; - - // Update the Redux store immediately for optimistic updates - const currentTask = allTasks.find(task => task.id === taskId); - if (currentTask) { - const updatedTask = { - ...currentTask, - custom_column_values: { - ...currentTask.custom_column_values, - [columnKey]: value, - }, - updated_at: new Date().toISOString(), - }; - - // Import and dispatch the updateTask action - import('@/features/task-management/task-management.slice').then(({ updateTask }) => { - dispatch(updateTask(updatedTask)); - }); - } - - if (socket && connected) { - socket.emit(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), JSON.stringify(body)); - } else { - console.warn('Socket not connected, unable to emit TASK_CUSTOM_COLUMN_UPDATE event'); - } - } catch (error) { - console.error('Error updating custom column value:', error); - } - }, [urlProjectId, socket, connected, allTasks, dispatch]); - - // Custom column settings handler - const handleCustomColumnSettings = useCallback((columnKey: string) => { - if (!columnKey) return; - - const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey); - - // Use the UUID for API calls, not the key (nanoid) - // For custom columns, prioritize the uuid field over id field - const columnId = (columnData as any)?.uuid || columnData?.id || columnKey; - - dispatch(setCustomColumnModalAttributes({ - modalType: 'edit', - columnId: columnId, - columnData: columnData - })); - dispatch(toggleCustomColumnModalOpen(true)); - }, [dispatch, visibleColumns]); - - // Add callback for task added - const handleTaskAdded = useCallback(() => { - // Task is now added in real-time via socket, no need to refetch - // The global socket handler will handle the real-time update - }, []); - - // Handle scroll synchronization - disabled since header is now sticky inside content - const handleContentScroll = useCallback(() => { - // No longer needed since header scrolls naturally with content - }, []); - - // Memoized values for GroupedVirtuoso - const virtuosoGroups = useMemo(() => { - let currentTaskIndex = 0; - - return groups.map(group => { - const isCurrentGroupCollapsed = collapsedGroups.has(group.id); - - const visibleTasksInGroup = isCurrentGroupCollapsed - ? [] - : group.taskIds - .map(taskId => allTasks.find(task => task.id === taskId)) - .filter((task): task is Task => task !== undefined); - - const tasksForVirtuoso = visibleTasksInGroup.map(task => ({ - ...task, - originalIndex: allTasks.indexOf(task), - })); - - const itemsWithAddTask = !isCurrentGroupCollapsed ? [ - ...tasksForVirtuoso, - { - id: `add-task-${group.id}`, - isAddTaskRow: true, - groupId: group.id, - groupType: currentGrouping || 'status', - groupValue: group.id, // Use the actual database ID from backend - projectId: urlProjectId, - } - ] : tasksForVirtuoso; - - const groupData = { - ...group, - tasks: itemsWithAddTask, - startIndex: currentTaskIndex, - count: itemsWithAddTask.length, - actualCount: group.taskIds.length, - groupValue: group.groupValue || group.title, - }; - currentTaskIndex += itemsWithAddTask.length; - return groupData; - }); - }, [groups, allTasks, collapsedGroups, currentGrouping, urlProjectId]); - - const virtuosoGroupCounts = useMemo(() => { - return virtuosoGroups.map(group => group.count); - }, [virtuosoGroups]); - - const virtuosoItems = useMemo(() => { - return virtuosoGroups.flatMap(group => group.tasks); - }, [virtuosoGroups]); - - // Render functions - const renderGroup = useCallback( - (groupIndex: number) => { - const group = virtuosoGroups[groupIndex]; - const isGroupCollapsed = collapsedGroups.has(group.id); - const isGroupEmpty = group.actualCount === 0; - - - - return ( -
0 ? 'mt-2' : ''}> - handleGroupCollapse(group.id)} - projectId={urlProjectId || ''} - /> - {isGroupEmpty && !isGroupCollapsed && ( -
-
- {visibleColumns.map((column, index) => ( -
- ))} -
-
-
- {t('noTasksInGroup')} -
-
-
- )} -
- ); - }, - [virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t] - ); - - const renderTask = useCallback( - (taskIndex: number) => { - const item = virtuosoItems[taskIndex]; - - - if (!item || !urlProjectId) return null; - - if ('isAddTaskRow' in item && item.isAddTaskRow) { - return ( - - ); - } - - return ( - - ); - }, - [virtuosoItems, visibleColumns, urlProjectId, handleTaskAdded, updateTaskCustomColumnValue] - ); - - // Render column headers - const renderColumnHeaders = useCallback(() => ( -
-
- {visibleColumns.map((column, index) => { - const columnStyle: ColumnStyle = { - width: column.width, - flexShrink: 0, - ...(column.id === 'labels' && column.width === 'auto' - ? { - minWidth: '200px', - flexGrow: 1, - } - : {}), - ...((column as any).minWidth && { minWidth: (column as any).minWidth }), - ...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }), - }; - - return ( -
- {column.id === 'dragHandle' || column.id === 'checkbox' ? ( - - ) : (column as any).isCustom ? ( - - ) : ( - t(column.label || '') - )} -
- ); - })} - {/* Add Custom Column Button - positioned at the end and scrolls with content */} -
- -
-
-
- ), [visibleColumns, t, handleCustomColumnSettings]); - - - - // Loading and error states - if (loading || loadingColumns) return ; - if (error) return
{t('emptyStates.errorPrefix')} {error}
; - - // Show message when no data - if (groups.length === 0 && !loading) { - return ( -
-
- -
-
-
-
- {t('emptyStates.noTaskGroups')} -
-
- {t('emptyStates.noTaskGroupsDescription')} -
-
-
-
- ); - } return ( - -
- {/* Task Filters */} -
- -
- - {/* Table Container */} -
- {/* Task List Content with Sticky Header */} -
- {/* 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 */} - - {activeId ? ( -
-
-
- -
-
- {allTasks.find(task => task.id === activeId)?.name || - allTasks.find(task => task.id === activeId)?.title || - t('emptyStates.dragTaskFallback')} -
-
- {allTasks.find(task => task.id === activeId)?.task_key} -
-
-
-
-
- ) : null} -
- - {/* Bulk Action Bar */} - {selectedTaskIds.length > 0 && urlProjectId && ( -
- bulkActions.handleBulkStatusChange(statusId, selectedTaskIds)} - onBulkPriorityChange={(priorityId) => bulkActions.handleBulkPriorityChange(priorityId, selectedTaskIds)} - onBulkPhaseChange={(phaseId) => bulkActions.handleBulkPhaseChange(phaseId, selectedTaskIds)} - onBulkAssignToMe={() => bulkActions.handleBulkAssignToMe(selectedTaskIds)} - onBulkAssignMembers={(memberIds) => bulkActions.handleBulkAssignMembers(memberIds, selectedTaskIds)} - onBulkAddLabels={(labelIds) => bulkActions.handleBulkAddLabels(labelIds, selectedTaskIds)} - onBulkArchive={() => bulkActions.handleBulkArchive(selectedTaskIds)} - onBulkDelete={() => bulkActions.handleBulkDelete(selectedTaskIds)} - onBulkDuplicate={() => bulkActions.handleBulkDuplicate(selectedTaskIds)} - onBulkExport={() => bulkActions.handleBulkExport(selectedTaskIds)} - onBulkSetDueDate={(date) => bulkActions.handleBulkSetDueDate(date, selectedTaskIds)} - /> -
- )} - - {/* Custom Column Modal */} - {createPortal(, document.body, 'custom-column-modal')} +
+ {/* Task Filters */} +
+
- + +
); }; diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx new file mode 100644 index 00000000..997a8256 --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -0,0 +1,678 @@ +import React, { useCallback, useMemo, useEffect, useState, useRef } from 'react'; +import { GroupedVirtuoso } from 'react-virtuoso'; +import { + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + KeyboardSensor, + TouchSensor, + closestCenter, +} from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { createPortal } from 'react-dom'; +import { Skeleton } from 'antd'; +import { HolderOutlined } from '@ant-design/icons'; + +// Redux hooks and selectors +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { + selectAllTasksArray, + selectGroups, + selectGrouping, + selectLoading, + selectError, + fetchTasksV3, + fetchTaskListColumns, + selectColumns, + selectCustomColumns, + selectLoadingColumns, + updateColumnVisibility, +} from '@/features/task-management/task-management.slice'; +import { + selectCurrentGrouping, + selectCollapsedGroups, + toggleGroupCollapsed, +} from '@/features/task-management/grouping.slice'; +import { + selectSelectedTaskIds, + selectLastSelectedTaskId, + selectTask, + toggleTaskSelection, + selectRange, + clearSelection, +} from '@/features/task-management/selection.slice'; +import { + setCustomColumnModalAttributes, + toggleCustomColumnModalOpen, +} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice'; + +// Components +import TaskRowWithSubtasks from './TaskRowWithSubtasks'; +import TaskGroupHeader from './TaskGroupHeader'; +import ImprovedTaskFilters from '@/components/task-management/improved-task-filters'; +import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar'; +import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal'; +import AddTaskRow from './components/AddTaskRow'; +import { + AddCustomColumnButton, + CustomColumnHeader, +} from './components/CustomColumnComponents'; + +// Hooks and utilities +import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; +import { useSocket } from '@/socket/socketContext'; +import { useDragAndDrop } from './hooks/useDragAndDrop'; +import { useBulkActions } from './hooks/useBulkActions'; + +// Constants and types +import { BASE_COLUMNS, ColumnStyle } from './constants/columns'; +import { Task } from '@/types/task-management.types'; +import { SocketEvents } from '@/shared/socket-events'; + +const TaskListV2Section: React.FC = () => { + const dispatch = useAppDispatch(); + const { projectId: urlProjectId } = useParams(); + const { t } = useTranslation('task-list-table'); + const { socket, connected } = useSocket(); + + // Redux state selectors + const allTasks = useAppSelector(selectAllTasksArray); + const groups = useAppSelector(selectGroups); + const grouping = useAppSelector(selectGrouping); + const loading = useAppSelector(selectLoading); + const error = useAppSelector(selectError); + const currentGrouping = useAppSelector(selectCurrentGrouping); + const selectedTaskIds = useAppSelector(selectSelectedTaskIds); + const lastSelectedTaskId = useAppSelector(selectLastSelectedTaskId); + const collapsedGroups = useAppSelector(selectCollapsedGroups); + + const fields = useAppSelector(state => state.taskManagementFields) || []; + const columns = useAppSelector(selectColumns); + const customColumns = useAppSelector(selectCustomColumns); + const loadingColumns = useAppSelector(selectLoadingColumns); + + // Refs for scroll synchronization + const headerScrollRef = useRef(null); + const contentScrollRef = useRef(null); + + // State hooks + const [initializedFromDatabase, setInitializedFromDatabase] = useState(false); + + // Configure sensors for drag and drop + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 250, + tolerance: 5, + }, + }) + ); + + // Custom hooks + const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(allTasks, groups); + const bulkActions = useBulkActions(); + + // Enable real-time updates via socket handlers + useTaskSocketHandlers(); + + // Filter visible columns based on local fields (primary) and backend columns (fallback) + const visibleColumns = useMemo(() => { + // Start with base columns + const baseVisibleColumns = BASE_COLUMNS.filter(column => { + // Always show drag handle and title (sticky columns) + if (column.isSticky) return true; + + // Primary: Check local fields configuration + const field = fields.find(f => f.key === column.key); + if (field) { + return field.visible; + } + + // Fallback: Check backend column configuration if local field not found + const backendColumn = columns.find(c => c.key === column.key); + if (backendColumn) { + return backendColumn.pinned ?? false; + } + + // Default: hide if neither local field nor backend column found + return false; + }); + + // Add visible custom columns + const visibleCustomColumns = customColumns + ?.filter(column => column.pinned) + ?.map(column => { + // Give selection columns more width for dropdown content + const fieldType = column.custom_column_obj?.fieldType; + let defaultWidth = 160; + if (fieldType === 'selection') { + defaultWidth = 150; // Reduced width for selection dropdowns + } else if (fieldType === 'people') { + defaultWidth = 170; // Extra width for people with avatars + } + + // Map the configuration data structure to the expected format + const customColumnObj = column.custom_column_obj || (column as any).configuration; + + // Transform configuration format to custom_column_obj format if needed + let transformedColumnObj = customColumnObj; + if (customColumnObj && !customColumnObj.fieldType && customColumnObj.field_type) { + transformedColumnObj = { + ...customColumnObj, + fieldType: customColumnObj.field_type, + numberType: customColumnObj.number_type, + labelPosition: customColumnObj.label_position, + previewValue: customColumnObj.preview_value, + firstNumericColumn: customColumnObj.first_numeric_column_key, + secondNumericColumn: customColumnObj.second_numeric_column_key, + selectionsList: customColumnObj.selections_list || customColumnObj.selectionsList || [], + labelsList: customColumnObj.labels_list || customColumnObj.labelsList || [], + }; + } + + return { + id: column.key || column.id || 'unknown', + label: column.name || t('customColumns.customColumnHeader'), + width: `${(column as any).width || defaultWidth}px`, + key: column.key || column.id || 'unknown', + custom_column: true, + custom_column_obj: transformedColumnObj, + isCustom: true, + name: column.name, + uuid: column.id, + }; + }) || []; + + return [...baseVisibleColumns, ...visibleCustomColumns]; + }, [fields, columns, customColumns, t]); + + // Effects + useEffect(() => { + if (urlProjectId) { + dispatch(fetchTasksV3(urlProjectId)); + dispatch(fetchTaskListColumns(urlProjectId)); + } + }, [dispatch, urlProjectId]); + + // Initialize field visibility from database when columns are loaded (only once) + useEffect(() => { + if (columns.length > 0 && fields.length > 0 && !initializedFromDatabase) { + // Update local fields to match database state only on initial load + import('@/features/task-management/taskListFields.slice').then(({ setFields }) => { + // Create updated fields based on database column state + const updatedFields = fields.map(field => { + const backendColumn = columns.find(c => c.key === field.key); + if (backendColumn) { + return { + ...field, + visible: backendColumn.pinned ?? field.visible + }; + } + return field; + }); + + // Only update if there are actual changes + const hasChanges = updatedFields.some((field, index) => + field.visible !== fields[index].visible + ); + + if (hasChanges) { + dispatch(setFields(updatedFields)); + } + + setInitializedFromDatabase(true); + }); + } + }, [columns, fields, dispatch, initializedFromDatabase]); + + // Event handlers + const handleTaskSelect = useCallback( + (taskId: string, event: React.MouseEvent) => { + if (event.ctrlKey || event.metaKey) { + dispatch(toggleTaskSelection(taskId)); + } else if (event.shiftKey && lastSelectedTaskId) { + const taskIds = allTasks.map(t => t.id); + const startIdx = taskIds.indexOf(lastSelectedTaskId); + const endIdx = taskIds.indexOf(taskId); + const rangeIds = taskIds.slice(Math.min(startIdx, endIdx), Math.max(startIdx, endIdx) + 1); + dispatch(selectRange(rangeIds)); + } else { + dispatch(clearSelection()); + dispatch(selectTask(taskId)); + } + }, + [dispatch, lastSelectedTaskId, allTasks] + ); + + const handleGroupCollapse = useCallback( + (groupId: string) => { + dispatch(toggleGroupCollapsed(groupId)); + }, + [dispatch] + ); + + // Function to update custom column values + const updateTaskCustomColumnValue = useCallback((taskId: string, columnKey: string, value: string) => { + try { + if (!urlProjectId) { + console.error('Project ID is missing'); + return; + } + + const body = { + task_id: taskId, + column_key: columnKey, + value: value, + project_id: urlProjectId, + }; + + // Update the Redux store immediately for optimistic updates + const currentTask = allTasks.find(task => task.id === taskId); + if (currentTask) { + const updatedTask = { + ...currentTask, + custom_column_values: { + ...currentTask.custom_column_values, + [columnKey]: value, + }, + updated_at: new Date().toISOString(), + }; + + // Import and dispatch the updateTask action + import('@/features/task-management/task-management.slice').then(({ updateTask }) => { + dispatch(updateTask(updatedTask)); + }); + } + + if (socket && connected) { + socket.emit(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), JSON.stringify(body)); + } else { + console.warn('Socket not connected, unable to emit TASK_CUSTOM_COLUMN_UPDATE event'); + } + } catch (error) { + console.error('Error updating custom column value:', error); + } + }, [urlProjectId, socket, connected, allTasks, dispatch]); + + // Custom column settings handler + const handleCustomColumnSettings = useCallback((columnKey: string) => { + if (!columnKey) return; + + const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey); + + // Use the UUID for API calls, not the key (nanoid) + // For custom columns, prioritize the uuid field over id field + const columnId = (columnData as any)?.uuid || columnData?.id || columnKey; + + dispatch(setCustomColumnModalAttributes({ + modalType: 'edit', + columnId: columnId, + columnData: columnData + })); + dispatch(toggleCustomColumnModalOpen(true)); + }, [dispatch, visibleColumns]); + + // Add callback for task added + const handleTaskAdded = useCallback(() => { + // Task is now added in real-time via socket, no need to refetch + // The global socket handler will handle the real-time update + }, []); + + // Handle scroll synchronization - disabled since header is now sticky inside content + const handleContentScroll = useCallback(() => { + // No longer needed since header scrolls naturally with content + }, []); + + // Memoized values for GroupedVirtuoso + const virtuosoGroups = useMemo(() => { + let currentTaskIndex = 0; + + return groups.map(group => { + const isCurrentGroupCollapsed = collapsedGroups.has(group.id); + + const visibleTasksInGroup = isCurrentGroupCollapsed + ? [] + : group.taskIds + .map(taskId => allTasks.find(task => task.id === taskId)) + .filter((task): task is Task => task !== undefined); + + const tasksForVirtuoso = visibleTasksInGroup.map(task => ({ + ...task, + originalIndex: allTasks.indexOf(task), + })); + + const itemsWithAddTask = !isCurrentGroupCollapsed ? [ + ...tasksForVirtuoso, + { + id: `add-task-${group.id}`, + isAddTaskRow: true, + groupId: group.id, + groupType: currentGrouping || 'status', + groupValue: group.id, // Use the actual database ID from backend + projectId: urlProjectId, + } + ] : tasksForVirtuoso; + + const groupData = { + ...group, + tasks: itemsWithAddTask, + startIndex: currentTaskIndex, + count: itemsWithAddTask.length, + actualCount: group.taskIds.length, + groupValue: group.groupValue || group.title, + }; + currentTaskIndex += itemsWithAddTask.length; + return groupData; + }); + }, [groups, allTasks, collapsedGroups, currentGrouping, urlProjectId]); + + const virtuosoGroupCounts = useMemo(() => { + return virtuosoGroups.map(group => group.count); + }, [virtuosoGroups]); + + const virtuosoItems = useMemo(() => { + return virtuosoGroups.flatMap(group => group.tasks); + }, [virtuosoGroups]); + + // Render functions + const renderGroup = useCallback( + (groupIndex: number) => { + const group = virtuosoGroups[groupIndex]; + const isGroupCollapsed = collapsedGroups.has(group.id); + const isGroupEmpty = group.actualCount === 0; + + + + return ( +
0 ? 'mt-2' : ''}> + handleGroupCollapse(group.id)} + projectId={urlProjectId || ''} + /> + {isGroupEmpty && !isGroupCollapsed && ( +
+
+ {visibleColumns.map((column, index) => ( +
+ ))} +
+
+
+ {t('noTasksInGroup')} +
+
+
+ )} +
+ ); + }, + [virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t] + ); + + const renderTask = useCallback( + (taskIndex: number) => { + const item = virtuosoItems[taskIndex]; + + + if (!item || !urlProjectId) return null; + + if ('isAddTaskRow' in item && item.isAddTaskRow) { + return ( + + ); + } + + return ( + + ); + }, + [virtuosoItems, visibleColumns, urlProjectId, handleTaskAdded, updateTaskCustomColumnValue] + ); + + // Render column headers + const renderColumnHeaders = useCallback(() => ( +
+
+ {visibleColumns.map((column, index) => { + const columnStyle: ColumnStyle = { + width: column.width, + flexShrink: 0, + ...(column.id === 'labels' && column.width === 'auto' + ? { + minWidth: '200px', + flexGrow: 1, + } + : {}), + ...((column as any).minWidth && { minWidth: (column as any).minWidth }), + ...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }), + }; + + return ( +
+ {column.id === 'dragHandle' || column.id === 'checkbox' ? ( + + ) : (column as any).isCustom ? ( + + ) : ( + t(column.label || '') + )} +
+ ); + })} + {/* Add Custom Column Button - positioned at the end and scrolls with content */} +
+ +
+
+
+ ), [visibleColumns, t, handleCustomColumnSettings]); + + + + // Loading and error states + if (loading || loadingColumns) return ; + if (error) return
{t('emptyStates.errorPrefix')} {error}
; + + // Show message when no data + if (groups.length === 0 && !loading) { + return ( +
+
+ +
+
+
+
+ {t('emptyStates.noTaskGroups')} +
+
+ {t('emptyStates.noTaskGroupsDescription')} +
+
+
+
+ ); + } + + return ( + +
+ + {/* Table Container */} +
+ {/* Task List Content with Sticky Header */} +
+ {/* 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 */} + + {activeId ? ( +
+
+
+ +
+
+ {allTasks.find(task => task.id === activeId)?.name || + allTasks.find(task => task.id === activeId)?.title || + t('emptyStates.dragTaskFallback')} +
+
+ {allTasks.find(task => task.id === activeId)?.task_key} +
+
+
+
+
+ ) : null} +
+ + {/* Bulk Action Bar */} + {selectedTaskIds.length > 0 && urlProjectId && ( +
+ bulkActions.handleBulkStatusChange(statusId, selectedTaskIds)} + onBulkPriorityChange={(priorityId) => bulkActions.handleBulkPriorityChange(priorityId, selectedTaskIds)} + onBulkPhaseChange={(phaseId) => bulkActions.handleBulkPhaseChange(phaseId, selectedTaskIds)} + onBulkAssignToMe={() => bulkActions.handleBulkAssignToMe(selectedTaskIds)} + onBulkAssignMembers={(memberIds) => bulkActions.handleBulkAssignMembers(memberIds, selectedTaskIds)} + onBulkAddLabels={(labelIds) => bulkActions.handleBulkAddLabels(labelIds, selectedTaskIds)} + onBulkArchive={() => bulkActions.handleBulkArchive(selectedTaskIds)} + onBulkDelete={() => bulkActions.handleBulkDelete(selectedTaskIds)} + onBulkDuplicate={() => bulkActions.handleBulkDuplicate(selectedTaskIds)} + onBulkExport={() => bulkActions.handleBulkExport(selectedTaskIds)} + onBulkSetDueDate={(date) => bulkActions.handleBulkSetDueDate(date, selectedTaskIds)} + /> +
+ )} + + {/* Custom Column Modal */} + {createPortal(, document.body, 'custom-column-modal')} +
+
+ ); +}; + +export default TaskListV2Section; From 399a01904aa59fa3040f479c362d6579d5ae95eb Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 14:58:54 +0530 Subject: [PATCH 37/39] refactor(task-list): enhance styling and structure in TaskListV2 and TaskRow components - Consolidated import statements for better readability. - Improved layout and styling consistency by adding border styles to various elements in TaskRow and AddTaskRow components. - Updated TaskListV2Table to enhance the rendering logic and maintainability. - Adjusted custom column handling and task estimation display for improved user experience. --- .../task-list-v2/TaskListV2Table.tsx | 586 ++++++++++-------- .../src/components/task-list-v2/TaskRow.tsx | 71 ++- .../task-list-v2/components/AddTaskRow.tsx | 12 +- .../task-management/task-management.slice.ts | 4 +- .../src/hooks/useTaskSocketHandlers.ts | 23 +- 5 files changed, 396 insertions(+), 300 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index 997a8256..99fb5bb1 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -62,10 +62,7 @@ import ImprovedTaskFilters from '@/components/task-management/improved-task-filt import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar'; import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal'; import AddTaskRow from './components/AddTaskRow'; -import { - AddCustomColumnButton, - CustomColumnHeader, -} from './components/CustomColumnComponents'; +import { AddCustomColumnButton, CustomColumnHeader } from './components/CustomColumnComponents'; // Hooks and utilities import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; @@ -126,7 +123,10 @@ const TaskListV2Section: React.FC = () => { ); // Custom hooks - const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(allTasks, groups); + const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop( + allTasks, + groups + ); const bulkActions = useBulkActions(); // Enable real-time updates via socket handlers @@ -156,49 +156,51 @@ const TaskListV2Section: React.FC = () => { }); // Add visible custom columns - const visibleCustomColumns = customColumns - ?.filter(column => column.pinned) - ?.map(column => { - // Give selection columns more width for dropdown content - const fieldType = column.custom_column_obj?.fieldType; - let defaultWidth = 160; - if (fieldType === 'selection') { - defaultWidth = 150; // Reduced width for selection dropdowns - } else if (fieldType === 'people') { - defaultWidth = 170; // Extra width for people with avatars - } + const visibleCustomColumns = + customColumns + ?.filter(column => column.pinned) + ?.map(column => { + // Give selection columns more width for dropdown content + const fieldType = column.custom_column_obj?.fieldType; + let defaultWidth = 160; + if (fieldType === 'selection') { + defaultWidth = 150; // Reduced width for selection dropdowns + } else if (fieldType === 'people') { + defaultWidth = 170; // Extra width for people with avatars + } - // Map the configuration data structure to the expected format - const customColumnObj = column.custom_column_obj || (column as any).configuration; + // Map the configuration data structure to the expected format + const customColumnObj = column.custom_column_obj || (column as any).configuration; - // Transform configuration format to custom_column_obj format if needed - let transformedColumnObj = customColumnObj; - if (customColumnObj && !customColumnObj.fieldType && customColumnObj.field_type) { - transformedColumnObj = { - ...customColumnObj, - fieldType: customColumnObj.field_type, - numberType: customColumnObj.number_type, - labelPosition: customColumnObj.label_position, - previewValue: customColumnObj.preview_value, - firstNumericColumn: customColumnObj.first_numeric_column_key, - secondNumericColumn: customColumnObj.second_numeric_column_key, - selectionsList: customColumnObj.selections_list || customColumnObj.selectionsList || [], - labelsList: customColumnObj.labels_list || customColumnObj.labelsList || [], + // Transform configuration format to custom_column_obj format if needed + let transformedColumnObj = customColumnObj; + if (customColumnObj && !customColumnObj.fieldType && customColumnObj.field_type) { + transformedColumnObj = { + ...customColumnObj, + fieldType: customColumnObj.field_type, + numberType: customColumnObj.number_type, + labelPosition: customColumnObj.label_position, + previewValue: customColumnObj.preview_value, + firstNumericColumn: customColumnObj.first_numeric_column_key, + secondNumericColumn: customColumnObj.second_numeric_column_key, + selectionsList: + customColumnObj.selections_list || customColumnObj.selectionsList || [], + labelsList: customColumnObj.labels_list || customColumnObj.labelsList || [], + }; + } + + return { + id: column.key || column.id || 'unknown', + label: column.name || t('customColumns.customColumnHeader'), + width: `${(column as any).width || defaultWidth}px`, + key: column.key || column.id || 'unknown', + custom_column: true, + custom_column_obj: transformedColumnObj, + isCustom: true, + name: column.name, + uuid: column.id, }; - } - - return { - id: column.key || column.id || 'unknown', - label: column.name || t('customColumns.customColumnHeader'), - width: `${(column as any).width || defaultWidth}px`, - key: column.key || column.id || 'unknown', - custom_column: true, - custom_column_obj: transformedColumnObj, - isCustom: true, - name: column.name, - uuid: column.id, - }; - }) || []; + }) || []; return [...baseVisibleColumns, ...visibleCustomColumns]; }, [fields, columns, customColumns, t]); @@ -222,15 +224,15 @@ const TaskListV2Section: React.FC = () => { if (backendColumn) { return { ...field, - visible: backendColumn.pinned ?? field.visible + visible: backendColumn.pinned ?? field.visible, }; } return field; }); // Only update if there are actual changes - const hasChanges = updatedFields.some((field, index) => - field.visible !== fields[index].visible + const hasChanges = updatedFields.some( + (field, index) => field.visible !== fields[index].visible ); if (hasChanges) { @@ -269,65 +271,73 @@ const TaskListV2Section: React.FC = () => { ); // Function to update custom column values - const updateTaskCustomColumnValue = useCallback((taskId: string, columnKey: string, value: string) => { - try { - if (!urlProjectId) { - console.error('Project ID is missing'); - return; - } + const updateTaskCustomColumnValue = useCallback( + (taskId: string, columnKey: string, value: string) => { + try { + if (!urlProjectId) { + console.error('Project ID is missing'); + return; + } - const body = { - task_id: taskId, - column_key: columnKey, - value: value, - project_id: urlProjectId, - }; - - // Update the Redux store immediately for optimistic updates - const currentTask = allTasks.find(task => task.id === taskId); - if (currentTask) { - const updatedTask = { - ...currentTask, - custom_column_values: { - ...currentTask.custom_column_values, - [columnKey]: value, - }, - updated_at: new Date().toISOString(), + const body = { + task_id: taskId, + column_key: columnKey, + value: value, + project_id: urlProjectId, }; - // Import and dispatch the updateTask action - import('@/features/task-management/task-management.slice').then(({ updateTask }) => { - dispatch(updateTask(updatedTask)); - }); - } + // Update the Redux store immediately for optimistic updates + const currentTask = allTasks.find(task => task.id === taskId); + if (currentTask) { + const updatedTask = { + ...currentTask, + custom_column_values: { + ...currentTask.custom_column_values, + [columnKey]: value, + }, + updated_at: new Date().toISOString(), + }; - if (socket && connected) { - socket.emit(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), JSON.stringify(body)); - } else { - console.warn('Socket not connected, unable to emit TASK_CUSTOM_COLUMN_UPDATE event'); + // Import and dispatch the updateTask action + import('@/features/task-management/task-management.slice').then(({ updateTask }) => { + dispatch(updateTask(updatedTask)); + }); + } + + if (socket && connected) { + socket.emit(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), JSON.stringify(body)); + } else { + console.warn('Socket not connected, unable to emit TASK_CUSTOM_COLUMN_UPDATE event'); + } + } catch (error) { + console.error('Error updating custom column value:', error); } - } catch (error) { - console.error('Error updating custom column value:', error); - } - }, [urlProjectId, socket, connected, allTasks, dispatch]); + }, + [urlProjectId, socket, connected, allTasks, dispatch] + ); // Custom column settings handler - const handleCustomColumnSettings = useCallback((columnKey: string) => { - if (!columnKey) return; + const handleCustomColumnSettings = useCallback( + (columnKey: string) => { + if (!columnKey) return; - const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey); + const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey); - // Use the UUID for API calls, not the key (nanoid) - // For custom columns, prioritize the uuid field over id field - const columnId = (columnData as any)?.uuid || columnData?.id || columnKey; + // Use the UUID for API calls, not the key (nanoid) + // For custom columns, prioritize the uuid field over id field + const columnId = (columnData as any)?.uuid || columnData?.id || columnKey; - dispatch(setCustomColumnModalAttributes({ - modalType: 'edit', - columnId: columnId, - columnData: columnData - })); - dispatch(toggleCustomColumnModalOpen(true)); - }, [dispatch, visibleColumns]); + dispatch( + setCustomColumnModalAttributes({ + modalType: 'edit', + columnId: columnId, + columnData: columnData, + }) + ); + dispatch(toggleCustomColumnModalOpen(true)); + }, + [dispatch, visibleColumns] + ); // Add callback for task added const handleTaskAdded = useCallback(() => { @@ -350,25 +360,27 @@ const TaskListV2Section: React.FC = () => { const visibleTasksInGroup = isCurrentGroupCollapsed ? [] : group.taskIds - .map(taskId => allTasks.find(task => task.id === taskId)) - .filter((task): task is Task => task !== undefined); + .map(taskId => allTasks.find(task => task.id === taskId)) + .filter((task): task is Task => task !== undefined); const tasksForVirtuoso = visibleTasksInGroup.map(task => ({ ...task, originalIndex: allTasks.indexOf(task), })); - const itemsWithAddTask = !isCurrentGroupCollapsed ? [ - ...tasksForVirtuoso, - { - id: `add-task-${group.id}`, - isAddTaskRow: true, - groupId: group.id, - groupType: currentGrouping || 'status', - groupValue: group.id, // Use the actual database ID from backend - projectId: urlProjectId, - } - ] : tasksForVirtuoso; + const itemsWithAddTask = !isCurrentGroupCollapsed + ? [ + ...tasksForVirtuoso, + { + id: `add-task-${group.id}`, + isAddTaskRow: true, + groupId: group.id, + groupType: currentGrouping || 'status', + groupValue: group.id, // Use the actual database ID from backend + projectId: urlProjectId, + }, + ] + : tasksForVirtuoso; const groupData = { ...group, @@ -398,8 +410,6 @@ const TaskListV2Section: React.FC = () => { const isGroupCollapsed = collapsedGroups.has(group.id); const isGroupEmpty = group.actualCount === 0; - - return (
0 ? 'mt-2' : ''}> { {isGroupEmpty && !isGroupCollapsed && (
- {visibleColumns.map((column, index) => ( -
- ))} + {visibleColumns.map((column, index) => { + const emptyColumnStyle = { + width: column.width, + flexShrink: 0, + ...(column.id === 'labels' && column.width === 'auto' + ? { minWidth: '200px', flexGrow: 1 } + : {}), + }; + return ( +
+ ); + })}
@@ -440,7 +460,6 @@ const TaskListV2Section: React.FC = () => { (taskIndex: number) => { const item = virtuosoItems[taskIndex]; - if (!item || !urlProjectId) return null; if ('isAddTaskRow' in item && item.isAddTaskRow) { @@ -469,70 +488,86 @@ const TaskListV2Section: React.FC = () => { ); // Render column headers - const renderColumnHeaders = useCallback(() => ( -
-
- {visibleColumns.map((column, index) => { - const columnStyle: ColumnStyle = { - width: column.width, - flexShrink: 0, - ...(column.id === 'labels' && column.width === 'auto' - ? { - minWidth: '200px', - flexGrow: 1, - } - : {}), - ...((column as any).minWidth && { minWidth: (column as any).minWidth }), - ...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }), - }; + const renderColumnHeaders = useCallback( + () => ( +
+
+ {visibleColumns.map((column, index) => { + const columnStyle: ColumnStyle = { + width: column.width, + flexShrink: 0, + ...(column.id === 'labels' && column.width === 'auto' + ? { + minWidth: '200px', + flexGrow: 1, + } + : {}), + ...((column as any).minWidth && { minWidth: (column as any).minWidth }), + ...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }), + }; - return ( -
- {column.id === 'dragHandle' || column.id === 'checkbox' ? ( - - ) : (column as any).isCustom ? ( - - ) : ( - t(column.label || '') - )} -
- ); - })} - {/* Add Custom Column Button - positioned at the end and scrolls with content */} -
- + style={columnStyle} + > + {column.id === 'dragHandle' || column.id === 'checkbox' ? ( + + ) : (column as any).isCustom ? ( + + ) : ( + t(column.label || '') + )} +
+ ); + })} + {/* Add Custom Column Button - positioned at the end and scrolls with content */} +
+ +
-
- ), [visibleColumns, t, handleCustomColumnSettings]); - - + ), + [visibleColumns, t, handleCustomColumnSettings] + ); // Loading and error states if (loading || loadingColumns) return ; - if (error) return
{t('emptyStates.errorPrefix')} {error}
; + if (error) + return ( +
+ {t('emptyStates.errorPrefix')} {error} +
+ ); // Show message when no data if (groups.length === 0 && !loading) { @@ -556,58 +591,61 @@ const TaskListV2Section: React.FC = () => { } return ( - -
- - {/* Table Container */} + +
+ {/* Table Container */} +
+ {/* Task List Content with Sticky Header */}
- {/* Task List Content with Sticky Header */} + {/* Sticky Column Headers */}
- {/* 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)} + {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; + {/* 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 (
@@ -615,63 +653,73 @@ const TaskListV2Section: React.FC = () => {
); })} -
- ))} -
-
-
+
+ ))} +
+
+
- {/* Drag Overlay */} - - {activeId ? ( -
-
-
- -
-
- {allTasks.find(task => task.id === activeId)?.name || - allTasks.find(task => task.id === activeId)?.title || - t('emptyStates.dragTaskFallback')} -
-
- {allTasks.find(task => task.id === activeId)?.task_key} -
+ {/* Drag Overlay */} + + {activeId ? ( +
+
+
+ +
+
+ {allTasks.find(task => task.id === activeId)?.name || + allTasks.find(task => task.id === activeId)?.title || + t('emptyStates.dragTaskFallback')} +
+
+ {allTasks.find(task => task.id === activeId)?.task_key}
- ) : null} - - - {/* Bulk Action Bar */} - {selectedTaskIds.length > 0 && urlProjectId && ( -
- bulkActions.handleBulkStatusChange(statusId, selectedTaskIds)} - onBulkPriorityChange={(priorityId) => bulkActions.handleBulkPriorityChange(priorityId, selectedTaskIds)} - onBulkPhaseChange={(phaseId) => bulkActions.handleBulkPhaseChange(phaseId, selectedTaskIds)} - onBulkAssignToMe={() => bulkActions.handleBulkAssignToMe(selectedTaskIds)} - onBulkAssignMembers={(memberIds) => bulkActions.handleBulkAssignMembers(memberIds, selectedTaskIds)} - onBulkAddLabels={(labelIds) => bulkActions.handleBulkAddLabels(labelIds, selectedTaskIds)} - onBulkArchive={() => bulkActions.handleBulkArchive(selectedTaskIds)} - onBulkDelete={() => bulkActions.handleBulkDelete(selectedTaskIds)} - onBulkDuplicate={() => bulkActions.handleBulkDuplicate(selectedTaskIds)} - onBulkExport={() => bulkActions.handleBulkExport(selectedTaskIds)} - onBulkSetDueDate={(date) => bulkActions.handleBulkSetDueDate(date, selectedTaskIds)} - />
- )} + ) : null} + - {/* Custom Column Modal */} - {createPortal(, document.body, 'custom-column-modal')} -
- + {/* Bulk Action Bar */} + {selectedTaskIds.length > 0 && urlProjectId && ( +
+ + bulkActions.handleBulkStatusChange(statusId, selectedTaskIds) + } + onBulkPriorityChange={priorityId => + bulkActions.handleBulkPriorityChange(priorityId, selectedTaskIds) + } + onBulkPhaseChange={phaseId => + bulkActions.handleBulkPhaseChange(phaseId, selectedTaskIds) + } + onBulkAssignToMe={() => bulkActions.handleBulkAssignToMe(selectedTaskIds)} + onBulkAssignMembers={memberIds => + bulkActions.handleBulkAssignMembers(memberIds, selectedTaskIds) + } + onBulkAddLabels={labelIds => + bulkActions.handleBulkAddLabels(labelIds, selectedTaskIds) + } + onBulkArchive={() => bulkActions.handleBulkArchive(selectedTaskIds)} + onBulkDelete={() => bulkActions.handleBulkDelete(selectedTaskIds)} + onBulkDuplicate={() => bulkActions.handleBulkDuplicate(selectedTaskIds)} + onBulkExport={() => bulkActions.handleBulkExport(selectedTaskIds)} + onBulkSetDueDate={date => bulkActions.handleBulkSetDueDate(date, selectedTaskIds)} + /> +
+ )} + + {/* Custom Column Modal */} + {createPortal(, document.body, 'custom-column-modal')} +
+ ); }; diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index bf2a663c..93510cd0 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -271,7 +271,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn case 'checkbox': return ( -
+
= memo(({ taskId, projectId, visibleColumn case 'taskKey': return ( -
+
{task.task_key || 'N/A'} @@ -291,7 +291,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn case 'title': return ( -
+
{/* Indentation for subtasks - tighter spacing */} {isSubtask &&
} @@ -417,7 +417,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn case 'description': return ( -
+
= memo(({ taskId, projectId, visibleColumn case 'status': return ( -
+
= memo(({ taskId, projectId, visibleColumn case 'assignees': return ( -
+
= memo(({ taskId, projectId, visibleColumn case 'priority': return ( -
+
= memo(({ taskId, projectId, visibleColumn case 'dueDate': return ( -
+
{activeDatePicker === 'dueDate' ? (
= memo(({ taskId, projectId, visibleColumn case 'progress': return ( -
+
{task.progress !== undefined && task.progress >= 0 && (task.progress === 100 ? ( @@ -555,8 +555,13 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn ); case 'labels': + const labelsColumn = visibleColumns.find(col => col.id === 'labels'); + const labelsStyle = { + ...baseStyle, + ...(labelsColumn?.width === 'auto' ? { minWidth: '200px', flexGrow: 1 } : {}) + }; return ( -
+
@@ -564,7 +569,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn case 'phase': return ( -
+
= memo(({ taskId, projectId, visibleColumn case 'timeTracking': return ( -
+
); case 'estimation': + // Use timeTracking.estimated which is the converted value from backend's total_minutes + const estimationDisplay = (() => { + const estimatedHours = task.timeTracking?.estimated; + + if (estimatedHours && estimatedHours > 0) { + // Convert decimal hours to hours and minutes for display + const hours = Math.floor(estimatedHours); + const minutes = Math.round((estimatedHours - hours) * 60); + + if (hours > 0 && minutes > 0) { + return `${hours}h ${minutes}m`; + } else if (hours > 0) { + return `${hours}h`; + } else if (minutes > 0) { + return `${minutes}m`; + } + } + + return null; + })(); + return ( -
- {task.timeTracking?.estimated ? ( +
+ {estimationDisplay ? ( - {task.timeTracking.estimated}h + {estimationDisplay} ) : ( - 0 + - )}
@@ -597,7 +623,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn case 'startDate': return ( -
+
{activeDatePicker === 'startDate' ? (
= memo(({ taskId, projectId, visibleColumn case 'completedDate': return ( -
+
{formattedDates.completed ? ( {formattedDates.completed} @@ -668,7 +694,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn case 'createdDate': return ( -
+
{formattedDates.created ? ( {formattedDates.created} @@ -680,9 +706,8 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn ); case 'lastUpdated': - console.log('formattedDates.updated', formattedDates.updated); return ( -
+
{formattedDates.updated ? ( {formattedDates.updated} @@ -695,7 +720,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn case 'reporter': return ( -
+
{task.reporter ? ( {task.reporter} ) : ( @@ -709,7 +734,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn const column = visibleColumns.find(col => col.id === columnId); if (column && (column.custom_column || column.isCustom) && updateTaskCustomColumnValue) { return ( -
+
= memo(({ case 'checkbox': case 'taskKey': case 'description': - return
; + return
; + case 'labels': + const labelsStyle = { + ...baseStyle, + ...(width === 'auto' ? { minWidth: '200px', flexGrow: 1 } : {}) + }; + return
; case 'title': return ( -
+
@@ -129,7 +135,7 @@ const AddTaskRow: React.FC = memo(({
); default: - return
; + return
; } }, [isAdding, taskName, handleAddTask, handleCancel, t]); 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 c555f6e3..c10805e1 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -281,8 +281,8 @@ export const fetchTasksV3 = createAsyncThunk( dueDate: task.dueDate, startDate: task.startDate, timeTracking: { - estimated: convertTimeValue(task.total_time), - logged: convertTimeValue(task.time_spent), + estimated: task.timeTracking?.estimated || 0, + logged: task.timeTracking?.logged || 0, }, customFields: {}, custom_column_values: task.custom_column_values || {}, diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index cf857653..cc55829b 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -670,15 +670,32 @@ export const useTaskSocketHandlers = () => { const handleEstimationChange = useCallback( - (task: { id: string; parent_task: string | null; estimation: number }) => { - if (!task) return; + (data: { id: string; parent_task: string | null; total_hours: number; total_minutes: number }) => { + if (!data) return; + // Update the old task slice (for backward compatibility) const taskWithProgress = { - ...task, + ...data, manual_progress: false, } as IProjectTask; dispatch(updateTaskEstimation({ task: taskWithProgress })); + + // Update task-management slice for task-list-v2 components + const currentTask = store.getState().taskManagement.entities[data.id]; + if (currentTask) { + const estimatedHours = (data.total_hours || 0) + (data.total_minutes || 0) / 60; + const updatedTask: Task = { + ...currentTask, + timeTracking: { + ...currentTask.timeTracking, + estimated: estimatedHours, + }, + updatedAt: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + dispatch(updateTask(updatedTask)); + } }, [dispatch] ); From 6f63041148864bc291411d9797b96d139a2c2a20 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 15:57:08 +0530 Subject: [PATCH 38/39] refactor(task-list): update status handling and enhance styling in TaskListV2Table - Modified status assignment in useTaskSocketHandlers to utilize actual status_id from the response for improved accuracy. - Simplified status logic by directly using data.status in task creation. - Enhanced styling in TaskListV2Table by adding border styles for better visual separation of elements. --- .../src/components/task-list-v2/TaskListV2Table.tsx | 4 ++-- worklenz-frontend/src/hooks/useTaskSocketHandlers.ts | 10 ++-------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index 99fb5bb1..172f1b74 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -515,7 +515,7 @@ const TaskListV2Section: React.FC = () => { return (
{ })} {/* Add Custom Column Button - positioned at the end and scrolls with content */}
diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index cc55829b..c00fa14d 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -232,7 +232,7 @@ export const useTaskSocketHandlers = () => { dispatch( updateTask({ ...currentTask, - status: newStatusValue, + status: response.status_id || newStatusValue, // Use actual status_id instead of category progress: response.complete_ratio || currentTask.progress, updatedAt: new Date().toISOString(), }) @@ -806,13 +806,7 @@ export const useTaskSocketHandlers = () => { task_key: data.task_key || '', title: data.name || '', description: data.description || '', - status: (data.status_category?.is_todo - ? 'todo' - : data.status_category?.is_doing - ? 'doing' - : data.status_category?.is_done - ? 'done' - : 'todo') as 'todo' | 'doing' | 'done', + status: data.status || 'todo', priority: (data.priority_value === 3 ? 'critical' : data.priority_value === 2 From deb0f3f6020284e48ed45757d3b47312842e574f Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 16:32:28 +0530 Subject: [PATCH 39/39] refactor(task-list): enhance task rendering and editing functionality in TaskRow and TaskListV2Table - Updated TaskListV2Table to pass isFirstInGroup prop to renderTask for improved task grouping logic. - Enhanced TaskRow to support inline editing of task names with a new input field and associated state management. - Implemented click outside detection to save task name changes when editing is complete. - Improved layout and styling for better user experience during task editing and display. --- .../task-list-v2/TaskListV2Table.tsx | 8 +- .../src/components/task-list-v2/TaskRow.tsx | 342 +++++++++++------- .../task-list-v2/TaskRowWithSubtasks.tsx | 3 + 3 files changed, 227 insertions(+), 126 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index 172f1b74..cc09e29e 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -457,7 +457,7 @@ const TaskListV2Section: React.FC = () => { ); const renderTask = useCallback( - (taskIndex: number) => { + (taskIndex: number, isFirstInGroup: boolean = false) => { const item = virtuosoItems[taskIndex]; if (!item || !urlProjectId) return null; @@ -480,6 +480,7 @@ const TaskListV2Section: React.FC = () => { taskId={item.id} projectId={urlProjectId} visibleColumns={visibleColumns} + isFirstInGroup={isFirstInGroup} updateTaskCustomColumnValue={updateTaskCustomColumnValue} /> ); @@ -647,9 +648,12 @@ const TaskListV2Section: React.FC = () => { virtuosoGroups.slice(0, groupIndex).reduce((sum, g) => sum + g.count, 0) + taskIndex; + // Check if this is the first actual task in the group (not AddTaskRow) + const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task); + return (
- {renderTask(globalTaskIndex)} + {renderTask(globalTaskIndex, isFirstTaskInGroup)}
); })} diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index 93510cd0..ba4e4370 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -1,8 +1,9 @@ -import React, { memo, useMemo, useCallback, useState } from 'react'; +import React, { memo, useMemo, useCallback, useState, useRef, useEffect } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { CheckCircleOutlined, HolderOutlined, CloseOutlined, DownOutlined, RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@ant-design/icons'; -import { Checkbox, DatePicker, Tooltip } from 'antd'; +import { Checkbox, DatePicker, Tooltip, Input } from 'antd'; +import type { InputRef } from 'antd'; import { dayjs, taskManagementAntdConfig } from '@/shared/antd-imports'; import { Task } from '@/types/task-management.types'; import { InlineMember } from '@/types/teamMembers/inlineMember.types'; @@ -40,6 +41,7 @@ interface TaskRowProps { isCustom?: boolean; }>; isSubtask?: boolean; + isFirstInGroup?: boolean; updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void; } @@ -97,7 +99,7 @@ const formatDate = (dateString: string): string => { } }; -const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumns, isSubtask = false, updateTaskCustomColumnValue }) => { +const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumns, isSubtask = false, isFirstInGroup = false, updateTaskCustomColumnValue }) => { const dispatch = useAppDispatch(); const task = useAppSelector(state => selectTaskById(state, taskId)); const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId)); @@ -106,6 +108,12 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn // State for tracking which date picker is open const [activeDatePicker, setActiveDatePicker] = useState(null); + + // State for editing task name + const [editTaskName, setEditTaskName] = useState(false); + const [taskName, setTaskName] = useState(task.title || task.name || ''); + const inputRef = useRef(null); + const wrapperRef = useRef(null); if (!task) { return null; // Don't render if task is not found in store @@ -153,6 +161,45 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn manual_progress: undefined, }), [task.id, taskDisplayName, task.task_key, task.assignee_names, task.parent_task_id]); + // Handle task name save + const handleTaskNameSave = useCallback(() => { + const newTaskName = inputRef.current?.input?.value || taskName; + if (newTaskName?.trim() !== '' && connected && newTaskName.trim() !== (task.title || task.name || '').trim()) { + socket?.emit( + SocketEvents.TASK_NAME_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + name: newTaskName.trim(), + parent_task: task.parent_task_id, + }) + ); + } + setEditTaskName(false); + }, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name]); + + // Handle click outside for task name editing + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) { + handleTaskNameSave(); + } + }; + + if (editTaskName) { + document.addEventListener('mousedown', handleClickOutside); + inputRef.current?.focus(); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [editTaskName, handleTaskNameSave]); + + // Update local taskName state when task name changes + useEffect(() => { + setTaskName(task.title || task.name || ''); + }, [task.title, task.name]); + // Memoize formatted dates const formattedDates = useMemo(() => ({ due: (() => { @@ -289,129 +336,169 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn
); - case 'title': + case 'title': return ( -
-
- {/* Indentation for subtasks - tighter spacing */} - {isSubtask &&
} - - {/* Expand/Collapse button - only show for parent tasks */} - {!isSubtask && ( - - )} - - {/* Additional indentation for subtasks after the expand button space */} - {isSubtask &&
} - -
- {/* Task name with dynamic width */} -
- - + {editTaskName ? ( + /* Full cell input when editing */ +
+ setTaskName(e.target.value)} + autoFocus + onPressEnter={handleTaskNameSave} + onBlur={handleTaskNameSave} + className="text-sm" + style={{ + width: '100%', + height: '38px', + margin: '0', + padding: '8px 12px', + border: '1px solid #1677ff', + backgroundColor: 'rgba(22, 119, 255, 0.02)', + borderRadius: '3px', + fontSize: '14px', + lineHeight: '22px', + boxSizing: 'border-box', + outline: 'none', + boxShadow: '0 0 0 2px rgba(22, 119, 255, 0.1)', + }} + /> +
+ ) : ( + /* Normal layout when not editing */ + <> +
+ {/* Indentation for subtasks - tighter spacing */} + {isSubtask &&
} + + {/* Expand/Collapse button - only show for parent tasks */} + {!isSubtask && ( + + )} + + {/* Additional indentation for subtasks after the expand button space */} + {isSubtask &&
} + +
+ {/* Task name with dynamic width */} +
+ { + e.stopPropagation(); + e.preventDefault(); + setEditTaskName(true); + }} + title={taskDisplayName} + > + {taskDisplayName} + +
+ + {/* Indicators container - flex-shrink-0 to prevent compression */} +
+ {/* Subtask count indicator - only show if count > 0 */} + {!isSubtask && task.sub_tasks_count != null && task.sub_tasks_count > 0 && ( + +
+ + {task.sub_tasks_count} + + +
+
+ )} + + {/* Task indicators - compact layout */} + {task.comments_count != null && task.comments_count !== 0 && ( + + + + )} + + {task.has_subscribers && ( + + + + )} + + {task.attachments_count != null && task.attachments_count !== 0 && ( + + + + )} + + {task.has_dependencies && ( + + + + )} + + {task.schedule_id && ( + + + + )} +
+
- {/* Indicators container - flex-shrink-0 to prevent compression */} -
- {/* Subtask count indicator - only show if count > 0 */} - {!isSubtask && task.sub_tasks_count != null && task.sub_tasks_count > 0 && ( - -
- - {task.sub_tasks_count} - - -
-
- )} - - {/* Task indicators - compact layout */} - {task.comments_count != null && task.comments_count !== 0 && ( - - - - )} - - {task.has_subscribers && ( - - - - )} - - {task.attachments_count != null && task.attachments_count !== 0 && ( - - - - )} - - {task.has_dependencies && ( - - - - )} - - {task.schedule_id && ( - - - - )} -
-
-
- - + + + )}
); @@ -755,6 +842,10 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn isDarkMode, projectId, + // Edit task name state - CRITICAL for re-rendering + editTaskName, + taskName, + // Task data - include specific fields that might update via socket task, task.labels, // Explicit dependency for labels updates @@ -775,6 +866,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn // Handlers handleDateChange, datePickerHandlers, + handleTaskNameSave, // Translation t, @@ -787,8 +879,10 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn return (
diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx index ae7489fd..f0a95fdb 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx @@ -20,6 +20,7 @@ interface TaskRowWithSubtasksProps { width: string; isSticky?: boolean; }>; + isFirstInGroup?: boolean; updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void; } @@ -153,6 +154,7 @@ const TaskRowWithSubtasks: React.FC = memo(({ taskId, projectId, visibleColumns, + isFirstInGroup = false, updateTaskCustomColumnValue }) => { const task = useAppSelector(state => selectTaskById(state, taskId)); @@ -175,6 +177,7 @@ const TaskRowWithSubtasks: React.FC = memo(({ taskId={taskId} projectId={projectId} visibleColumns={visibleColumns} + isFirstInGroup={isFirstInGroup} updateTaskCustomColumnValue={updateTaskCustomColumnValue} />