From 11694de4e6958e81bc160268edc07fef3d7d3f1f Mon Sep 17 00:00:00 2001 From: kithmina1999 Date: Fri, 6 Jun 2025 12:38:46 +0530 Subject: [PATCH 01/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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 f1d504f98519f4aec8a2f78a93d9ea1b26c8c914 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Tue, 24 Jun 2025 22:28:33 +0530 Subject: [PATCH 08/38] Update LANGUAGE_TYPE enum to include 'alb' and 'de' for Albanian and German languages --- worklenz-backend/database/sql/1_tables.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worklenz-backend/database/sql/1_tables.sql b/worklenz-backend/database/sql/1_tables.sql index af6cdc0e..e9fc31c4 100644 --- a/worklenz-backend/database/sql/1_tables.sql +++ b/worklenz-backend/database/sql/1_tables.sql @@ -12,7 +12,7 @@ CREATE TYPE DEPENDENCY_TYPE AS ENUM ('blocked_by'); CREATE TYPE SCHEDULE_TYPE AS ENUM ('daily', 'weekly', 'yearly', 'monthly', 'every_x_days', 'every_x_weeks', 'every_x_months'); -CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt'); +CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt', 'alb', 'de'); -- START: Users CREATE SEQUENCE IF NOT EXISTS users_user_no_seq START 1; From 39e09bedd3f45eabefc1e092611bcb1fd8fc8e29 Mon Sep 17 00:00:00 2001 From: jiuhao47 Date: Mon, 30 Jun 2025 20:52:49 +0800 Subject: [PATCH 09/38] 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 b500c801ee27c91739c3adf02020d3d1e2efb4fa Mon Sep 17 00:00:00 2001 From: shancds Date: Mon, 7 Jul 2025 10:11:56 +0530 Subject: [PATCH 10/38] refactor(KanbanGroup, TaskCard): simplify card creation logic and enhance drag-and-drop functionality - Removed conditional checks for user roles when displaying the new task card options in KanbanGroup, streamlining the UI for task creation. - Updated TaskCard component to improve the drag-and-drop indicator styling and structure, enhancing the user experience during task interactions. --- .../EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx | 6 +++--- .../EnhancedKanbanBoardNativeDnD/TaskCard.tsx | 14 ++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx index 190d5d84..6a38af8b 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx @@ -434,7 +434,7 @@ const KanbanGroup: React.FC = memo(({ )}
{/* Create card at top */} - {showNewCardTop && (isOwnerOrAdmin || isProjectManager) && ( + {showNewCardTop && ( = memo(({ ))} {/* Create card at bottom */} - {showNewCardBottom && (isOwnerOrAdmin || isProjectManager) && ( + {showNewCardBottom && ( = memo(({ )} {/* Footer Add Task Button */} - {(isOwnerOrAdmin || isProjectManager) && !showNewCardTop && !showNewCardBottom && group.tasks.length > 0 && ( + {!showNewCardTop && !showNewCardBottom && group.tasks.length > 0 && (
- {task.show_sub_tasks && ( +
{/* Loading state */} {task.sub_tasks_loading && ( @@ -471,7 +480,7 @@ const TaskCard: React.FC = memo(({
{t('noSubtasks', 'No subtasks')}
)}
- )} +
); From 8dcd0295e57bea69f8a5278ce7ed6713a69da0ec Mon Sep 17 00:00:00 2001 From: shancds Date: Mon, 7 Jul 2025 12:05:05 +0530 Subject: [PATCH 12/38] refactor(EnhancedKanbanBoard): improve drag-and-drop handling and task index management - Updated drag-and-drop event handlers in EnhancedKanbanBoard to support null task indices, enhancing flexibility during task interactions. - Adjusted KanbanGroup component to reflect changes in task index handling, ensuring consistent behavior when dragging tasks over empty drop zones. - Enhanced the visual structure of the KanbanGroup to improve user experience during task creation and management. --- .../EnhancedKanbanBoardNativeDnD.tsx | 10 +- .../KanbanGroup.tsx | 618 +++++++++--------- 2 files changed, 324 insertions(+), 304 deletions(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx index e8068e07..803f4026 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx @@ -120,15 +120,19 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project setDragType('task'); e.dataTransfer.effectAllowed = 'move'; }; - const handleTaskDragOver = (e: React.DragEvent, groupId: string, taskIdx: number) => { + const handleTaskDragOver = (e: React.DragEvent, groupId: string, taskIdx: number | null) => { if (dragType !== 'task') return; e.preventDefault(); if (draggedTaskId) { setHoveredGroupId(groupId); - setHoveredTaskIdx(taskIdx); } + if(taskIdx === null) { + setHoveredTaskIdx(0); + }else{ + setHoveredTaskIdx(taskIdx); + }; }; - const handleTaskDrop = (e: React.DragEvent, targetGroupId: string, targetTaskIdx: number) => { + const handleTaskDrop = (e: React.DragEvent, targetGroupId: string, targetTaskIdx: number | null) => { if (dragType !== 'task') return; e.preventDefault(); if (!draggedTaskId || !draggedTaskGroupId || hoveredGroupId === null || hoveredTaskIdx === null) return; diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx index 6a38af8b..d5e814ba 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx @@ -17,12 +17,12 @@ import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service import { ITaskPhase } from '@/types/tasks/taskPhase.types'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import { - deleteStatusToggleDrawer, - seletedStatusCategory, + deleteStatusToggleDrawer, + seletedStatusCategory, } from '@/features/projects/status/DeleteStatusSlice'; import { - fetchEnhancedKanbanGroups, - IGroupBy, + fetchEnhancedKanbanGroups, + IGroupBy, } from '@/features/enhanced-kanban/enhanced-kanban.slice'; @@ -32,8 +32,8 @@ interface KanbanGroupProps { onGroupDragOver: (e: React.DragEvent) => void; onGroupDrop: (e: React.DragEvent, groupId: string) => void; onTaskDragStart: (e: React.DragEvent, taskId: string, groupId: string) => void; - onTaskDragOver: (e: React.DragEvent, groupId: string, taskIdx: number) => void; - onTaskDrop: (e: React.DragEvent, groupId: string, taskIdx: number) => void; + onTaskDragOver: (e: React.DragEvent, groupId: string, taskIdx: number | null) => void; + onTaskDrop: (e: React.DragEvent, groupId: string, taskIdx: number | null) => void; hoveredTaskIdx: number | null; hoveredGroupId: string | null; } @@ -229,324 +229,340 @@ const KanbanGroup: React.FC = memo(({ }, [showDropdown]); return ( -
+
+ {/* Background layer - z-index 0 */}
onGroupDragStart(e, group.id)} - onDragOver={onGroupDragOver} - onDrop={e => onGroupDrop(e, group.id)} - > + onDragOver={e => { e.preventDefault(); onTaskDragOver(e, group.id, null); }} + onDrop={e => { e.preventDefault(); onTaskDrop(e, group.id, null); }} + /> + + {/* Content layer - z-index 1 */} +
setIsHover(true)} - onMouseLeave={() => setIsHover(false)} + className="enhanced-kanban-group-header" + style={{ + backgroundColor: headerBackgroundColor, + }} + draggable + onDragStart={e => onGroupDragStart(e, group.id)} + onDragOver={onGroupDragOver} + onDrop={e => onGroupDrop(e, group.id)} >
{ - e.stopPropagation(); - if ((isProjectManager || isOwnerOrAdmin) && group.name !== t('unmapped')) - setIsEditable(true); - }} - onMouseDown={e => { - e.stopPropagation(); - }} + className="flex items-center justify-between w-full font-semibold rounded-md" + onMouseEnter={() => setIsHover(true)} + onMouseLeave={() => setIsHover(false)} > - {isLoading && ( -
- )} - {isEditable ? ( - { - e.stopPropagation(); - }} - onClick={e => { - e.stopPropagation(); - }} - /> - ) : ( -
{ - e.stopPropagation(); - e.preventDefault(); - }} - onMouseUp={e => { - e.stopPropagation(); - }} - onClick={e => { - e.stopPropagation(); - }} - > - {name} ({group.tasks.length}) -
- )} -
- -
- - - {(isOwnerOrAdmin || isProjectManager) && name !== t('unmapped') && ( -
- - - {showDropdown && ( -
-
- - - {groupBy === IGroupBy.STATUS && statusCategories && ( -
-
- {t('changeCategory')} -
- {statusCategories.map(status => ( - - ))} -
- )} - - {groupBy !== IGroupBy.PRIORITY && ( -
- -
- )} -
-
- )} -
- )} -
-
-
- - {/* Simple Delete Confirmation */} - {showDeleteConfirm && ( -
-
-
-
-
- - - -
-
-

- {t('deleteConfirmationTitle')} -

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

+ {t('deleteConfirmationTitle')} +

+
+
+
+ + +
- )} - {(isOwnerOrAdmin || isProjectManager) && !showNewCardTop && !showNewCardBottom && ( - - )} -
- ) - } - - - {/* Drop indicator at the top of the group */} - {hoveredGroupId === group.id && hoveredTaskIdx === 0 && ( -
-
+
)} +
+ {/* Create card at top */} + {showNewCardTop && ( + + )} - {group.tasks.map((task, idx) => ( - - ))} + {/* If group is empty, render a drop zone */} + {group.tasks.length === 0 && !showNewCardTop && !showNewCardBottom && ( +
{ e.preventDefault(); onTaskDragOver(e, group.id, 0); }} + onDrop={e => { e.preventDefault(); onTaskDrop(e, group.id, 0); }} + > + {/* Drop indicator at the end of the group */} + {hoveredGroupId === group.id && hoveredTaskIdx === group.tasks.length && ( +
+
+
+ )} + {(isOwnerOrAdmin || isProjectManager) && !showNewCardTop && !showNewCardBottom && ( + + )} +
+ ) + } - {/* Create card at bottom */} - {showNewCardBottom && ( - - )} - {/* Footer Add Task Button */} - {!showNewCardTop && !showNewCardBottom && group.tasks.length > 0 && ( - - )} + {/* Drop indicator at the top of the group */} + {hoveredGroupId === group.id && hoveredTaskIdx === 0 && ( +
+
+
+ )} - {/* Drop indicator at the end of the group */} - {hoveredGroupId === group.id && hoveredTaskIdx === group.tasks.length && ( -
-
-
- )} + {group.tasks.map((task, idx) => ( + + ))} + + {/* Create card at bottom */} + {showNewCardBottom && ( + + )} + + {/* Footer Add Task Button */} + {!showNewCardTop && !showNewCardBottom && group.tasks.length > 0 && ( + + )} + + {/* Drop indicator at the end of the group */} + {hoveredGroupId === group.id && hoveredTaskIdx === group.tasks.length && ( +
+
+
+ )} +
- -
); }); From 26b47aac5311dfb020bd11a673fe7aa8ccc251a1 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 7 Jul 2025 12:31:11 +0530 Subject: [PATCH 13/38] 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 14/38] 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 15/38] 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 16/38] 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 5a9ceb4a94a682182a2b3760ba1df4ac6c0c247c Mon Sep 17 00:00:00 2001 From: shancds Date: Mon, 7 Jul 2025 13:12:50 +0530 Subject: [PATCH 17/38] feat(EnhancedKanbanBoard): add task dependency check during drag-and-drop - Implemented a check for task dependencies when moving tasks between groups in the EnhancedKanbanBoard component. - Integrated alert notifications to inform users if a task cannot be moved due to incomplete dependencies, enhancing user experience and task management integrity. --- .../EnhancedKanbanBoardNativeDnD.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx index 803f4026..fcddff24 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx @@ -20,6 +20,7 @@ import { statusApiService } from '@/api/taskAttributes/status/status.api.service import alertService from '@/services/alerts/alertService'; import logger from '@/utils/errorLogger'; import Skeleton from 'antd/es/skeleton/Skeleton'; +import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status'; const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ projectId }) => { const dispatch = useDispatch(); @@ -132,7 +133,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project setHoveredTaskIdx(taskIdx); }; }; - const handleTaskDrop = (e: React.DragEvent, targetGroupId: string, targetTaskIdx: number | null) => { + const handleTaskDrop = async (e: React.DragEvent, targetGroupId: string, targetTaskIdx: number | null) => { if (dragType !== 'task') return; e.preventDefault(); if (!draggedTaskId || !draggedTaskGroupId || hoveredGroupId === null || hoveredTaskIdx === null) return; @@ -142,10 +143,23 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project const targetGroup = taskGroups.find(g => g.id === targetGroupId); if (!sourceGroup || !targetGroup) return; + const taskIdx = sourceGroup.tasks.findIndex(t => t.id === draggedTaskId); if (taskIdx === -1) return; const movedTask = sourceGroup.tasks[taskIdx]; + if (groupBy === 'status' && movedTask.id) { + if (sourceGroup.id !== targetGroup.id) { + const canContinue = await checkTaskDependencyStatus(movedTask.id, targetGroupId); + if (!canContinue) { + alertService.error( + 'Task is not completed', + 'Please complete the task dependencies before proceeding' + ); + return; + } + } + } let insertIdx = hoveredTaskIdx; // Handle same group reordering @@ -239,6 +253,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project task: movedTask, team_id: teamId, }); + } setDraggedTaskId(null); From a3f317cbeb0b1e4806d53eb29015da3dd4075cd9 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 7 Jul 2025 13:16:26 +0530 Subject: [PATCH 18/38] 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 19/38] 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 20/38] 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 21/38] 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 22/38] 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 23/38] 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 8e62594eff8d93e46cc3ac1b80ded7b7feeeac5e Mon Sep 17 00:00:00 2001 From: shancds Date: Mon, 7 Jul 2025 15:17:33 +0530 Subject: [PATCH 24/38] fix(TaskCard): update background color for improved UI consistency - Changed the background color of the TaskCard component from '#f0f0f0' to '#E2EAF4' in light mode to enhance visual consistency and user experience. --- .../enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx index b103aeff..e6b855fb 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx @@ -210,7 +210,7 @@ const TaskCard: React.FC = memo(({ >
From b0253135e5c7b87fa4db8aad8af71022aacf3842 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 7 Jul 2025 17:07:45 +0530 Subject: [PATCH 25/38] 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 fa9e765e372779b45d337a8c7d6bc1a37969f219 Mon Sep 17 00:00:00 2001 From: shancds Date: Mon, 7 Jul 2025 17:20:34 +0530 Subject: [PATCH 26/38] refactor(KanbanGroup, TaskCard): enhance drag-and-drop indicators and task rendering - Added visual drop indicators before and after task cards in the KanbanGroup component to improve user experience during drag-and-drop operations. - Removed the isDropIndicator prop from TaskCard as the drop indicator logic is now handled within the KanbanGroup, simplifying the TaskCard component. - Updated drag-and-drop event handling in TaskCard to better manage task positioning during drag operations, enhancing overall functionality. --- .../KanbanGroup.tsx | 32 +++++++++++++------ .../EnhancedKanbanBoardNativeDnD/TaskCard.tsx | 27 ++++++---------- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx index d5e814ba..0e3e8a6d 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx @@ -517,17 +517,29 @@ const KanbanGroup: React.FC = memo(({ )} {group.tasks.map((task, idx) => ( - + + {/* Drop indicator before this card */} + {hoveredGroupId === group.id && hoveredTaskIdx === idx && ( +
+
+
+ )} + + ))} + {/* Drop indicator at the end of the group */} + {hoveredGroupId === group.id && hoveredTaskIdx === group.tasks.length && ( +
+
+
+ )} {/* Create card at bottom */} {showNewCardBottom && ( diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx index e6b855fb..0a503ba2 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx @@ -28,7 +28,6 @@ interface TaskCardProps { onTaskDragOver: (e: React.DragEvent, groupId: string, taskIdx: number) => void; onTaskDrop: (e: React.DragEvent, groupId: string, taskIdx: number) => void; groupId: string; - isDropIndicator: boolean; idx: number; } @@ -46,7 +45,6 @@ const TaskCard: React.FC = memo(({ onTaskDragOver, onTaskDrop, groupId, - isDropIndicator, idx }) => { const { socket } = useSocket(); @@ -198,29 +196,22 @@ const TaskCard: React.FC = memo(({ while (week.length < 7) week.push(null); weeks.push(week); } + const [isDown, setIsDown] = useState(false); return ( <> - {isDropIndicator && ( -
onTaskDragStart(e, task.id!, groupId)} - onDragOver={e => onTaskDragOver(e, groupId, idx)} - onDrop={e => onTaskDrop(e, groupId, idx)} - > -
-
- )}
onTaskDragStart(e, task.id!, groupId)} - onDragOver={e => onTaskDragOver(e, groupId, idx)} + onDragOver={e => { + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const offsetY = e.clientY - rect.top; + const isDown = offsetY > rect.height / 2; + setIsDown(isDown); + onTaskDragOver(e, groupId, isDown ? idx + 1 : idx); + }} onDrop={e => onTaskDrop(e, groupId, idx)} onClick={e => handleCardClick(e, task.id!)} From d150747f83fa4bcf597c7cb0f9cdc61050b3f2d5 Mon Sep 17 00:00:00 2001 From: shancds Date: Tue, 8 Jul 2025 10:57:34 +0530 Subject: [PATCH 27/38] refactor(ProjectViewEnhancedBoard): remove unused EnhancedKanbanBoard import --- .../projectView/enhancedBoard/project-view-enhanced-board.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/worklenz-frontend/src/pages/projects/projectView/enhancedBoard/project-view-enhanced-board.tsx b/worklenz-frontend/src/pages/projects/projectView/enhancedBoard/project-view-enhanced-board.tsx index 39ec6c4c..62e6dd03 100644 --- a/worklenz-frontend/src/pages/projects/projectView/enhancedBoard/project-view-enhanced-board.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/enhancedBoard/project-view-enhanced-board.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { useAppSelector } from '@/hooks/useAppSelector'; -import EnhancedKanbanBoard from '@/components/enhanced-kanban/EnhancedKanbanBoard'; import EnhancedKanbanBoardNativeDnD from '@/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD'; const ProjectViewEnhancedBoard: React.FC = () => { @@ -12,7 +11,6 @@ const ProjectViewEnhancedBoard: React.FC = () => { return (
- {/* */}
); From a44b276269bf3664e356f585c0223ec0ee844813 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 8 Jul 2025 11:59:17 +0530 Subject: [PATCH 28/38] 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 2aab2a21b6e4b718f1f7bb724e4c75c66a1d70b2 Mon Sep 17 00:00:00 2001 From: shancds Date: Tue, 8 Jul 2025 12:13:23 +0530 Subject: [PATCH 29/38] feat(EnhancedKanbanBoard): implement drag end handling for improved task interaction - Added handleDragEnd function to reset hovered task and group states after drag operations in EnhancedKanbanBoard. - Updated KanbanGroup and TaskCard components to support onDragEnd prop, enhancing drag-and-drop functionality and user experience during task management. --- .../EnhancedKanbanBoardNativeDnD.tsx | 6 ++++ .../KanbanGroup.tsx | 30 +++++++++++++++---- .../EnhancedKanbanBoardNativeDnD/TaskCard.tsx | 6 ++-- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx index fcddff24..1b81906e 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx @@ -263,6 +263,11 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project setDragType(null); }; + const handleDragEnd = () => { + setHoveredGroupId(null); + setHoveredTaskIdx(null); + }; + useEffect(() => { if (!socket) return; @@ -332,6 +337,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project onTaskDragStart={handleTaskDragStart} onTaskDragOver={handleTaskDragOver} onTaskDrop={handleTaskDrop} + onDragEnd={handleDragEnd} hoveredTaskIdx={hoveredGroupId === group.id ? hoveredTaskIdx : null} hoveredGroupId={hoveredGroupId} /> diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx index 0e3e8a6d..24b42c04 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx @@ -34,6 +34,7 @@ interface KanbanGroupProps { onTaskDragStart: (e: React.DragEvent, taskId: string, groupId: string) => void; onTaskDragOver: (e: React.DragEvent, groupId: string, taskIdx: number | null) => void; onTaskDrop: (e: React.DragEvent, groupId: string, taskIdx: number | null) => void; + onDragEnd: (e: React.DragEvent) => void; hoveredTaskIdx: number | null; hoveredGroupId: string | null; } @@ -46,6 +47,7 @@ const KanbanGroup: React.FC = memo(({ onTaskDragStart, onTaskDragOver, onTaskDrop, + onDragEnd, hoveredTaskIdx, hoveredGroupId }) => { @@ -259,6 +261,7 @@ const KanbanGroup: React.FC = memo(({ onDragStart={e => onGroupDragStart(e, group.id)} onDragOver={onGroupDragOver} onDrop={e => onGroupDrop(e, group.id)} + onDragEnd={onDragEnd} >
= memo(({ {/* Drop indicator before this card */} {hoveredGroupId === group.id && hoveredTaskIdx === idx && ( -
-
+
onTaskDragOver(e, group.id, idx)} + onDrop={e => onTaskDrop(e, group.id, idx)} + > +
)} = memo(({ onTaskDrop={onTaskDrop} groupId={group.id} idx={idx} + onDragEnd={onDragEnd} /> ))} {/* Drop indicator at the end of the group */} {hoveredGroupId === group.id && hoveredTaskIdx === group.tasks.length && ( -
-
-
+
onTaskDragOver(e, group.id, group.tasks.length)} + onDrop={e => onTaskDrop(e, group.id, group.tasks.length)} + > +
+
)} {/* Create card at bottom */} diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx index 0a503ba2..fea952f0 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx @@ -29,6 +29,7 @@ interface TaskCardProps { onTaskDrop: (e: React.DragEvent, groupId: string, taskIdx: number) => void; groupId: string; idx: number; + onDragEnd: (e: React.DragEvent) => void; // <-- add this } function getDaysInMonth(year: number, month: number) { @@ -45,7 +46,8 @@ const TaskCard: React.FC = memo(({ onTaskDragOver, onTaskDrop, groupId, - idx + idx, + onDragEnd // <-- add this }) => { const { socket } = useSocket(); const themeMode = useSelector((state: RootState) => state.themeReducer.mode); @@ -213,7 +215,7 @@ const TaskCard: React.FC = memo(({ onTaskDragOver(e, groupId, isDown ? idx + 1 : idx); }} onDrop={e => onTaskDrop(e, groupId, idx)} - + onDragEnd={onDragEnd} // <-- add this onClick={e => handleCardClick(e, task.id!)} >
From 03b3f554008746bbf2091d170e4bded0836a3f83 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 8 Jul 2025 12:43:00 +0530 Subject: [PATCH 30/38] 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 ee6055934c0700376d9d2cda8da57814adbaa833 Mon Sep 17 00:00:00 2001 From: shancds Date: Tue, 8 Jul 2025 12:49:49 +0530 Subject: [PATCH 31/38] refactor(AssigneeSelector): streamline component logic and enhance dropdown behavior - Removed unused kanbanMode prop and related logic to simplify the AssigneeSelector component. - Updated dropdown position handling to improve visibility and responsiveness during interactions. - Optimized member selection logic for better performance and user feedback. - Enhanced the rendering of team members with improved visual feedback for pending changes. --- .../src/components/AssigneeSelector.tsx | 379 ++++++++---------- 1 file changed, 169 insertions(+), 210 deletions(-) diff --git a/worklenz-frontend/src/components/AssigneeSelector.tsx b/worklenz-frontend/src/components/AssigneeSelector.tsx index 5f2bffcc..91866b7d 100644 --- a/worklenz-frontend/src/components/AssigneeSelector.tsx +++ b/worklenz-frontend/src/components/AssigneeSelector.tsx @@ -5,29 +5,25 @@ import { PlusOutlined, UserAddOutlined } from '@ant-design/icons'; import { RootState } from '@/app/store'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types'; -import { InlineMember } from '@/types/teamMembers/inlineMember.types'; import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import { useAuthService } from '@/hooks/useAuth'; -import { Avatar, Checkbox } from '@/components'; +import { Avatar, Button, Checkbox } from '@/components'; import { sortTeamMembers } from '@/utils/sort-team-members'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice'; -import { updateTaskAssignees } from '@/features/task-management/task-management.slice'; -import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types'; +import { updateEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice'; interface AssigneeSelectorProps { task: IProjectTask; groupId?: string | null; isDarkMode?: boolean; - kanbanMode?: boolean; // <-- Add this prop } -const AssigneeSelector: React.FC = ({ - task, - groupId = null, - isDarkMode = false, - kanbanMode = false, // <-- Default to false +const AssigneeSelector: React.FC = ({ + task, + groupId = null, + isDarkMode = false }) => { const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); @@ -35,12 +31,6 @@ const AssigneeSelector: React.FC = ({ const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }); const [optimisticAssignees, setOptimisticAssignees] = useState([]); // For optimistic updates const [pendingChanges, setPendingChanges] = useState>(new Set()); // Track pending member changes - - // Initialize optimistic assignees from task data on mount or when task changes - useEffect(() => { - const currentAssigneeIds = task?.assignees?.map(a => a.team_member_id) || []; - setOptimisticAssignees(currentAssigneeIds); - }, [task?.assignees]); const dropdownRef = useRef(null); const buttonRef = useRef(null); const searchInputRef = useRef(null); @@ -61,16 +51,9 @@ const AssigneeSelector: React.FC = ({ const updateDropdownPosition = useCallback(() => { if (buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect(); - const viewportHeight = window.innerHeight; - const dropdownHeight = 280; // More accurate height: header(40) + max-height(192) + footer(40) + padding - - // Check if dropdown would go below viewport - const spaceBelow = viewportHeight - rect.bottom; - const shouldShowAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight; - setDropdownPosition({ - top: shouldShowAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4, - left: rect.left, + top: rect.bottom + window.scrollY + 2, + left: rect.left + window.scrollX, }); } }, []); @@ -78,21 +61,27 @@ const AssigneeSelector: React.FC = ({ // Close dropdown when clicking outside and handle scroll useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) && - buttonRef.current && - !buttonRef.current.contains(event.target as Node) - ) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) && + buttonRef.current && !buttonRef.current.contains(event.target as Node)) { setIsOpen(false); } }; - const handleScroll = (event: Event) => { + const handleScroll = () => { if (isOpen) { - // Only close dropdown if scrolling happens outside the dropdown - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); + // Check if the button is still visible in the viewport + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + const isVisible = rect.top >= 0 && rect.left >= 0 && + rect.bottom <= window.innerHeight && + rect.right <= window.innerWidth; + + if (isVisible) { + updateDropdownPosition(); + } else { + // Hide dropdown if button is not visible + setIsOpen(false); + } } } }; @@ -107,7 +96,7 @@ const AssigneeSelector: React.FC = ({ document.addEventListener('mousedown', handleClickOutside); window.addEventListener('scroll', handleScroll, true); window.addEventListener('resize', handleResize); - + return () => { document.removeEventListener('mousedown', handleClickOutside); window.removeEventListener('scroll', handleScroll, true); @@ -122,22 +111,19 @@ const AssigneeSelector: React.FC = ({ const handleDropdownToggle = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - + if (!isOpen) { updateDropdownPosition(); - - // Prepare team members data when opening - use optimistic assignees for current state - const currentAssigneeIds = optimisticAssignees.length > 0 - ? optimisticAssignees - : task?.assignees?.map(assignee => assignee.team_member_id) || []; - const membersData: (ITeamMembersViewModel & { selected?: boolean })[] = (members?.data || []).map(member => ({ + // Prepare team members data when opening + const assignees = task?.assignees?.map(assignee => assignee.team_member_id); + const membersData = (members?.data || []).map(member => ({ ...member, - selected: currentAssigneeIds.includes(member.id), + selected: assignees?.includes(member.id), })); const sortedMembers = sortTeamMembers(membersData); setTeamMembers({ data: sortedMembers }); - + setIsOpen(true); // Focus search input after opening setTimeout(() => { @@ -154,20 +140,16 @@ const AssigneeSelector: React.FC = ({ // Add to pending changes for visual feedback setPendingChanges(prev => new Set(prev).add(memberId)); - // Get the current list of assignees, prioritizing optimistic updates for immediate feedback - const currentAssigneeIds = optimisticAssignees.length > 0 - ? optimisticAssignees - : task?.assignees?.map(a => a.team_member_id) || []; - + // OPTIMISTIC UPDATE: Update local state immediately for instant UI feedback + const currentAssignees = task?.assignees?.map(a => a.team_member_id) || []; let newAssigneeIds: string[]; if (checked) { - // Adding assignee: ensure no duplicates - const uniqueIds = new Set([...currentAssigneeIds, memberId]); - newAssigneeIds = Array.from(uniqueIds); + // Adding assignee + newAssigneeIds = [...currentAssignees, memberId]; } else { // Removing assignee - newAssigneeIds = currentAssigneeIds.filter(id => id !== memberId); + newAssigneeIds = currentAssignees.filter(id => id !== memberId); } // Update optimistic state for immediate UI feedback in dropdown @@ -176,9 +158,11 @@ const AssigneeSelector: React.FC = ({ // Update local team members state for dropdown UI setTeamMembers(prev => ({ ...prev, - data: (prev.data || []).map(member => - member.id === memberId ? { ...member, selected: checked } : member - ), + data: (prev.data || []).map(member => + member.id === memberId + ? { ...member, selected: checked } + : member + ) })); const body = { @@ -192,35 +176,17 @@ const AssigneeSelector: React.FC = ({ // Emit socket event - the socket handler will update Redux with proper types socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body)); - socket?.once(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), (data: any) => { - // Instead of updating enhancedKanbanSlice, update the main taskManagementSlice - // Filter members to get the actual InlineMember objects for the new assignees - const updatedAssigneeNames: InlineMember[] = (members?.data || []) - .filter((member): member is ITeamMemberViewModel & { id: string; name: string } => { - return typeof member.id === 'string' && typeof member.name === 'string' && newAssigneeIds.includes(member.id); - }) - .map(member => ({ - name: member.name || '', - id: member.id || '', - team_member_id: member.id || '', - avatar_url: member.avatar_url || '', - color_code: member.color_code || '', - })); - - dispatch(updateTaskAssignees({ - taskId: task.id || '', - assigneeIds: newAssigneeIds, - assigneeNames: updatedAssigneeNames, - })); - if (kanbanMode) { + socket?.once( + SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), + (data: any) => { dispatch(updateEnhancedKanbanTaskAssignees(data)); } - }); + ); // Remove from pending changes after a short delay (optimistic) setTimeout(() => { setPendingChanges(prev => { - const newSet = new Set(Array.from(prev)); + const newSet = new Set(prev); newSet.delete(memberId); return newSet; }); @@ -229,8 +195,11 @@ const AssigneeSelector: React.FC = ({ const checkMemberSelected = (memberId: string) => { if (!memberId) return false; - // Always use optimistic assignees for dropdown display - return optimisticAssignees.includes(memberId); + // Use optimistic assignees if available, otherwise fall back to task assignees + const assignees = optimisticAssignees.length > 0 + ? optimisticAssignees + : task?.assignees?.map(assignee => assignee.team_member_id) || []; + return assignees.includes(memberId); }; const handleInviteProjectMemberDrawer = () => { @@ -246,159 +215,149 @@ const AssigneeSelector: React.FC = ({ className={` w-5 h-5 rounded-full border border-dashed flex items-center justify-center transition-colors duration-200 - ${ - isOpen - ? isDarkMode - ? 'border-blue-500 bg-blue-900/20 text-blue-400' - : 'border-blue-500 bg-blue-50 text-blue-600' - : isDarkMode - ? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400' - : 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600' + ${isOpen + ? isDarkMode + ? 'border-blue-500 bg-blue-900/20 text-blue-400' + : 'border-blue-500 bg-blue-50 text-blue-600' + : isDarkMode + ? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400' + : 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600' } `} > - {isOpen && - createPortal( -
e.stopPropagation()} - className={` + {isOpen && createPortal( +
e.stopPropagation()} + className={` fixed z-9999 w-72 rounded-md shadow-lg border - ${isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200'} + ${isDarkMode + ? 'bg-gray-800 border-gray-600' + : 'bg-white border-gray-200' + } `} - style={{ - top: dropdownPosition.top, - left: dropdownPosition.left, - }} - > - {/* Header */} -
- setSearchQuery(e.target.value)} - placeholder="Search members..." - className={` + style={{ + top: dropdownPosition.top, + left: dropdownPosition.left, + }} + > + {/* Header */} +
+ setSearchQuery(e.target.value)} + placeholder="Search members..." + className={` w-full px-2 py-1 text-xs rounded border - ${ - isDarkMode - ? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500' - : 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500' + ${isDarkMode + ? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500' + : 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500' } focus:outline-none focus:ring-1 focus:ring-blue-500 `} - /> -
+ /> +
- {/* Members List */} -
- {filteredMembers && filteredMembers.length > 0 ? ( - filteredMembers.map(member => ( -
+ {filteredMembers && filteredMembers.length > 0 ? ( + filteredMembers.map((member) => ( +
{ - if (!member.pending_invitation) { - const isSelected = checkMemberSelected(member.id || ''); - handleMemberToggle(member.id || '', !isSelected); - } - }} - style={{ - // Add visual feedback for immediate response - transition: 'all 0.15s ease-in-out', - }} - > -
- e.stopPropagation()}> - handleMemberToggle(member.id || '', checked)} - disabled={ - member.pending_invitation || pendingChanges.has(member.id || '') - } - isDarkMode={isDarkMode} - /> - - {pendingChanges.has(member.id || '') && ( -
-
-
+ onClick={() => { + if (!member.pending_invitation) { + const isSelected = checkMemberSelected(member.id || ''); + handleMemberToggle(member.id || '', !isSelected); + } + }} + style={{ + // Add visual feedback for immediate response + transition: 'all 0.15s ease-in-out', + }} + > +
+ e.stopPropagation()}> + handleMemberToggle(member.id || '', checked)} + disabled={member.pending_invitation || pendingChanges.has(member.id || '')} + isDarkMode={isDarkMode} + /> + + {pendingChanges.has(member.id || '') && ( +
+
+
+ )} +
+ + + +
+
+ {member.name} +
+
+ {member.email} + {member.pending_invitation && ( + (Pending) )}
- - - -
-
- {member.name} -
-
- {member.email} - {member.pending_invitation && ( - (Pending) - )} -
-
- )) - ) : ( -
-
No members found
- )} -
+ )) + ) : ( +
+
No members found
+
+ )} +
- {/* Footer */} -
- -
-
, - document.body - )} + onClick={handleInviteProjectMemberDrawer} + > + + Invite member + +
+
, + document.body + )} ); }; -export default AssigneeSelector; +export default AssigneeSelector; \ No newline at end of file From e2e57fbf266529e8b0978c64d374bd93b61c7a20 Mon Sep 17 00:00:00 2001 From: shancds Date: Tue, 8 Jul 2025 12:59:31 +0530 Subject: [PATCH 32/38] fix(KanbanGroup): enhance input focus behavior to select text on focus - Updated the focus handling in KanbanGroup to select all text in the input field when focused, improving user experience during task editing. --- .../EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx index 24b42c04..23b6613a 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx @@ -199,7 +199,10 @@ const KanbanGroup: React.FC = memo(({ setIsEditable(true); setShowDropdown(false); setTimeout(() => { - inputRef.current?.focus(); + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); // Select all text on focus + } }, 100); }; From f06851fa378cecd6a4ccdf1265e1f539e1808653 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 8 Jul 2025 15:26:55 +0530 Subject: [PATCH 33/38] 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 35/38] 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 36/38] 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