From 14bf3b810c98bb6a48ed72c7912254c78034e31c Mon Sep 17 00:00:00 2001 From: jayaruperera Date: Fri, 25 Jul 2025 10:43:07 +0530 Subject: [PATCH 1/3] feat: Enhance task timer functionality and improve state management - Updated activeTimers structure to store userId and startTime for each task. - Implemented syncActiveTimersFromTasks helper function to synchronize active timers from task data. - Modified fetchTaskGroups and fetchSubTasks to return userId along with task data. - Added conflict check when starting a timer to prevent multiple active timers for the same user. - Improved task timer initialization logic to handle initialStartTime correctly. - Translated various UI strings in project insights and settings pages for better localization. - Refactored project view header to include CSV import functionality and improved tooltip texts. - Enhanced team settings page with localized titles and tooltips. --- worklenz-backend/package-lock.json | 119 ++++++- worklenz-backend/package.json | 4 +- .../socket.io/commands/on-task-timer-start.ts | 41 ++- .../public/locales/de/csv-import.json | 31 ++ .../public/locales/de/project-view.json | 8 + .../de/project-view/project-view-header.json | 7 +- .../en/admin-center/configuration.json | 17 + .../public/locales/en/common.json | 8 +- .../public/locales/en/csv-import.json | 38 +++ .../public/locales/en/kanban-board.json | 1 + .../public/locales/en/project-insights.json | 14 + .../locales/en/project-view-insights.json | 20 +- .../public/locales/en/project-view.json | 8 + .../en/project-view/project-view-header.json | 7 +- .../public/locales/en/settings/teams.json | 10 + .../es/admin-center/configuration.json | 17 + .../public/locales/es/kanban-board.json | 1 + .../public/locales/es/project-insights.json | 14 + .../locales/es/project-view-insights.json | 20 +- .../public/locales/es/project-view.json | 8 + .../es/project-view/project-view-header.json | 5 +- .../public/locales/es/reporting-sidebar.json | 10 +- .../locales/es/settings/categories.json | 16 +- .../public/locales/es/settings/teams.json | 10 + .../public/locales/nl/common.json | 0 .../public/locales/nl/project-view.json | 8 + .../public/locales/nl/settings/teams.json | 10 + .../public/locales/pt/common.json | 8 +- .../public/locales/pt/project-insights.json | 10 + .../locales/pt/project-view-insights.json | 21 +- .../public/locales/pt/project-view.json | 9 + .../pt/project-view/project-view-header.json | 5 +- .../public/locales/pt/reporting-members.json | 28 +- .../public/locales/pt/reporting-sidebar.json | 10 +- .../configuration/configuration.tsx | 50 +-- .../csv-import/csv-import-modal.css | 35 ++ .../csv-import/csv-import-modal.tsx | 305 ++++++++++++++++++ .../src/features/tasks/tasks.slice.ts | 85 ++++- worklenz-frontend/src/hooks/useTaskTimer.ts | 103 +++++- .../src/lib/project/project-view-constants.ts | 4 +- .../tables/assigned-tasks-list.tsx | 22 +- .../tables/last-updated-tasks.tsx | 16 +- .../tables/project-deadline.tsx | 24 +- .../insights/member-stats/member-stats.tsx | 8 +- .../insights/project-view-insights.tsx | 6 +- .../projectView/project-view-header.tsx | 40 ++- .../projects/projectView/project-view.tsx | 4 +- .../pages/settings/teams/teams-settings.tsx | 18 +- 48 files changed, 1111 insertions(+), 152 deletions(-) create mode 100644 worklenz-frontend/public/locales/de/csv-import.json create mode 100644 worklenz-frontend/public/locales/de/project-view.json create mode 100644 worklenz-frontend/public/locales/en/admin-center/configuration.json create mode 100644 worklenz-frontend/public/locales/en/csv-import.json create mode 100644 worklenz-frontend/public/locales/en/project-insights.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/admin-center/configuration.json create mode 100644 worklenz-frontend/public/locales/es/project-insights.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/nl/common.json create mode 100644 worklenz-frontend/public/locales/nl/project-view.json create mode 100644 worklenz-frontend/public/locales/nl/settings/teams.json create mode 100644 worklenz-frontend/public/locales/pt/project-insights.json create mode 100644 worklenz-frontend/public/locales/pt/project-view.json create mode 100644 worklenz-frontend/src/components/csv-import/csv-import-modal.css create mode 100644 worklenz-frontend/src/components/csv-import/csv-import-modal.tsx diff --git a/worklenz-backend/package-lock.json b/worklenz-backend/package-lock.json index 2953defa9..dcbc47270 100644 --- a/worklenz-backend/package-lock.json +++ b/worklenz-backend/package-lock.json @@ -25,6 +25,7 @@ "cron": "^2.4.0", "crypto-js": "^4.1.1", "csurf": "^1.11.0", + "csv-parser": "^3.2.0", "debug": "^4.3.4", "dotenv": "^16.3.1", "exceljs": "^4.3.0", @@ -42,6 +43,7 @@ "moment": "^2.29.4", "moment-timezone": "^0.5.43", "morgan": "^1.10.0", + "multer": "^2.0.1", "nanoid": "^3.3.6", "passport": "^0.7.0", "passport-google-oauth2": "^0.2.0", @@ -49,7 +51,6 @@ "passport-local": "^1.0.0", "path": "^0.12.7", "pg": "^8.14.1", - "pg-native": "^3.3.0", "pug": "^3.0.2", "redis": "^4.6.7", "sanitize-html": "^2.11.0", @@ -84,6 +85,7 @@ "@types/lodash": "^4.14.196", "@types/mime-types": "^2.1.1", "@types/morgan": "^1.9.4", + "@types/multer": "^2.0.0", "@types/node": "^18.17.1", "@types/passport": "^1.0.17", "@types/passport-google-oauth20": "^2.0.16", @@ -5433,6 +5435,16 @@ "@types/node": "*" } }, + "node_modules/@types/multer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "18.17.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.17.1.tgz", @@ -6053,6 +6065,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", @@ -6743,8 +6761,7 @@ "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, "node_modules/buffer-indexof-polyfill": { "version": "1.0.2", @@ -6771,6 +6788,17 @@ "node": ">=0.10.0" } }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", @@ -7174,6 +7202,21 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/connect-flash": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/connect-flash/-/connect-flash-0.1.1.tgz", @@ -7454,6 +7497,18 @@ "node": ">=0.6" } }, + "node_modules/csv-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz", + "integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==", + "license": "MIT", + "bin": { + "csv-parser": "bin/csv-parser" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -12609,6 +12664,8 @@ "integrity": "sha512-/DDvQCiXP0KBMZ31U2mmURKaxoKt9kNqqgrSO2RuBKS+OJjw5b7uHi5jFoV8zPAUa2TNtq2XfcWL1OWDEyjwlg==", "hasInstallScript": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "bindings": "1.5.0", "nan": "2.22.0" @@ -13210,6 +13267,36 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/multer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz", + "integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/multer/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/nan": { "version": "2.22.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", @@ -13906,6 +13993,8 @@ "resolved": "https://registry.npmjs.org/pg-native/-/pg-native-3.3.0.tgz", "integrity": "sha512-8GHZOx20B/wceRebDG2KK2KZbmDmwkoLvWz4X7BQIF1fjRLCNp48oHsEHSk1lTw36GFGMksLiJ3qZcmSAgVdYA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "libpq": "1.8.14", "pg-types": "^2.1.0" @@ -13916,6 +14005,8 @@ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", @@ -13932,6 +14023,8 @@ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=4" } @@ -13941,6 +14034,8 @@ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13950,6 +14045,8 @@ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13959,6 +14056,8 @@ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "xtend": "^4.0.0" }, @@ -15444,6 +15543,14 @@ "node": ">= 0.10.0" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/streamx": { "version": "2.15.5", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.5.tgz", @@ -16170,6 +16277,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", diff --git a/worklenz-backend/package.json b/worklenz-backend/package.json index f3faaaecc..1f01007ee 100644 --- a/worklenz-backend/package.json +++ b/worklenz-backend/package.json @@ -46,6 +46,7 @@ "cron": "^2.4.0", "crypto-js": "^4.1.1", "csurf": "^1.11.0", + "csv-parser": "^3.2.0", "debug": "^4.3.4", "dotenv": "^16.3.1", "exceljs": "^4.3.0", @@ -63,6 +64,7 @@ "moment": "^2.29.4", "moment-timezone": "^0.5.43", "morgan": "^1.10.0", + "multer": "^2.0.1", "nanoid": "^3.3.6", "passport": "^0.7.0", "passport-google-oauth2": "^0.2.0", @@ -70,7 +72,6 @@ "passport-local": "^1.0.0", "path": "^0.12.7", "pg": "^8.14.1", - "pg-native": "^3.3.0", "pug": "^3.0.2", "redis": "^4.6.7", "sanitize-html": "^2.11.0", @@ -105,6 +106,7 @@ "@types/lodash": "^4.14.196", "@types/mime-types": "^2.1.1", "@types/morgan": "^1.9.4", + "@types/multer": "^2.0.0", "@types/node": "^18.17.1", "@types/passport": "^1.0.17", "@types/passport-google-oauth20": "^2.0.16", diff --git a/worklenz-backend/src/socket.io/commands/on-task-timer-start.ts b/worklenz-backend/src/socket.io/commands/on-task-timer-start.ts index 554217f14..c27c4e07c 100644 --- a/worklenz-backend/src/socket.io/commands/on-task-timer-start.ts +++ b/worklenz-backend/src/socket.io/commands/on-task-timer-start.ts @@ -7,16 +7,49 @@ import {getLoggedInUserIdFromSocket, log_error, notifyProjectUpdates} from "../u export async function on_task_timer_start(_io: Server, socket: Socket, data?: string) { try { - const q = ` + const body = JSON.parse(data as string); + const userId = getLoggedInUserIdFromSocket(socket); + + // First, stop any existing timers for this user + const stopExistingTimersQ = ` + DO + $$ + DECLARE + _timer RECORD; + _start_time TIMESTAMPTZ; + _time_spent NUMERIC; + BEGIN + -- Process all existing timers for this user + FOR _timer IN SELECT task_id, start_time FROM task_timers WHERE user_id = $1 AND task_id != $2 + LOOP + _time_spent = COALESCE(EXTRACT(EPOCH FROM + (DATE_TRUNC('second', (CURRENT_TIMESTAMP - _timer.start_time::TIMESTAMPTZ)))::INTERVAL), + 0); + + IF (_time_spent > 0) + THEN + INSERT INTO task_work_log (time_spent, task_id, user_id, logged_by_timer, created_at) + VALUES (_time_spent, _timer.task_id, $1, TRUE, _timer.start_time); + END IF; + END LOOP; + + -- Delete all existing timers for this user except the current task + DELETE FROM task_timers WHERE user_id = $1 AND task_id != $2; + END + $$; + `; + + await db.query(stopExistingTimersQ, [userId, body.task_id]); + + // Now start the new timer + const startTimerQ = ` INSERT INTO task_timers (task_id, user_id, start_time) VALUES ($1, $2, CURRENT_TIMESTAMP) ON CONFLICT ON CONSTRAINT task_timers_pk DO UPDATE SET start_time = CURRENT_TIMESTAMP RETURNING start_time; `; - const body = JSON.parse(data as string); - const userId = getLoggedInUserIdFromSocket(socket); - const result = await db.query(q, [body.task_id, userId]); + const result = await db.query(startTimerQ, [body.task_id, userId]); const [d] = result.rows; socket.emit(SocketEvents.TASK_TIMER_START.toString(), { id: body.task_id, diff --git a/worklenz-frontend/public/locales/de/csv-import.json b/worklenz-frontend/public/locales/de/csv-import.json new file mode 100644 index 000000000..b9c323e18 --- /dev/null +++ b/worklenz-frontend/public/locales/de/csv-import.json @@ -0,0 +1,31 @@ +{ + "title": "Aufgaben aus CSV importieren", + "cancel": "Abbrechen", + "importTasks": "Aufgaben importieren", + "instructions": { + "title": "Anweisungen", + "upload": "Laden Sie eine CSV-Datei hoch, um Aufgaben zu importieren.", + "format": "Die Datei muss genau zwei Spalten in dieser Reihenfolge enthalten: Aufgabentitel und Beschreibung.", + "header": "Die erste Zeile sollte die Kopfzeile sein und wird übersprungen." + }, + "downloadTemplate": "Beispiel-CSV-Vorlage herunterladen", + "upload": { + "title": "Klicken oder ziehen Sie die CSV-Datei in diesen Bereich zum Hochladen", + "hint": "Nur CSV-Dateien werden unterstützt. Maximale Dateigröße ist 5MB." + }, + "validation": { + "ready": "Datei bereit zum Import", + "selected": "Ausgewählte Datei", + "invalidType": "Bitte wählen Sie eine CSV-Datei", + "tooLarge": "Dateigröße muss weniger als 5MB sein" + }, + "errors": { + "uploadFailed": "Datei-Upload fehlgeschlagen", + "importFailed": "Import der Aufgaben fehlgeschlagen", + "noFile": "Bitte wählen Sie eine CSV-Datei" + }, + "success": { + "imported": "Import abgeschlossen. {{count}} Aufgaben wurden erfolgreich erstellt.", + "importedWithSkipped": "Import abgeschlossen. {{tasksCreated}} Aufgaben wurden erstellt. {{rowsSkipped}} Zeilen wurden übersprungen aufgrund fehlender Titel." + } +} 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 000000000..69a5dce9d --- /dev/null +++ b/worklenz-frontend/public/locales/de/project-view.json @@ -0,0 +1,8 @@ +{ + "tasksList": "Aufgabenliste", + "board": "Board", + "Insights": "Einblicke", + "Files": "Dateien", + "Members": "Mitglieder", + "Updates": "Updates" +} \ 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 ad236a04b..74348ff26 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 @@ -1,5 +1,7 @@ { "importTasks": "Aufgaben importieren", + "importTask": "Aufgabe importieren", + "importFromCSV": "Aus CSV importieren", "createTask": "Aufgabe erstellen", "settings": "Einstellungen", "subscribe": "Abonnieren", @@ -9,5 +11,8 @@ "endDate": "Enddatum", "projectSettings": "Projekteinstellungen", "projectSummary": "Projektzusammenfassung", - "receiveProjectSummary": "Erhalten Sie jeden Abend eine Projektzusammenfassung." + "receiveProjectSummary": "Erhalten Sie jeden Abend eine Projektzusammenfassung.", + "refreshProject": "Projekt aktualisieren", + "saveAsTemplate": "Als Vorlage speichern", + "invite": "Einladen" } diff --git a/worklenz-frontend/public/locales/en/admin-center/configuration.json b/worklenz-frontend/public/locales/en/admin-center/configuration.json new file mode 100644 index 000000000..ba170fa29 --- /dev/null +++ b/worklenz-frontend/public/locales/en/admin-center/configuration.json @@ -0,0 +1,17 @@ +{ + "billingDetails": "Billing Details", + "companyDetails": "Company Details", + "name": "Name", + "emailAddress": "Email Address", + "contactNumber": "Contact Number", + "companyName": "Company Name", + "addressLine01": "Address Line 01", + "addressLine02": "Address Line 02", + "country": "Country", + "city": "City", + "state": "State", + "postalCode": "Postal Code", + "save": "Save", + "phoneNumber": "Phone Number", + "phoneNumberValidation": "Phone number must be exactly 10 digits" +} diff --git a/worklenz-frontend/public/locales/en/common.json b/worklenz-frontend/public/locales/en/common.json index 815560beb..540670c86 100644 --- a/worklenz-frontend/public/locales/en/common.json +++ b/worklenz-frontend/public/locales/en/common.json @@ -5,5 +5,11 @@ "signup-failed": "Signup failed. Please ensure all required fields are filled and try again.", "reconnecting": "Disconnected from server.", "connection-lost": "Failed to connect to server. Please check your internet connection.", - "connection-restored": "Connected to server successfully" + "connection-restored": "Connected to server successfully", + "timer": { + "conflictTitle": "Timer Already Running", + "conflictMessage": "You have an active timer running on \"{{taskName}}\". Do you want to stop it and start a new timer on this task?", + "stopAndStartNew": "Stop & Start New", + "cancel": "Cancel" + } } diff --git a/worklenz-frontend/public/locales/en/csv-import.json b/worklenz-frontend/public/locales/en/csv-import.json new file mode 100644 index 000000000..f1f8888cd --- /dev/null +++ b/worklenz-frontend/public/locales/en/csv-import.json @@ -0,0 +1,38 @@ +{ + "title": "Import Tasks from CSV", + "cancel": "Cancel", + "importTasks": "Import Tasks", + "instructions": { + "title": "Instructions", + "upload": "Upload a CSV file to import tasks.", + "format": "The file must contain exactly two columns in this order: Task Title and Description.", + "header": "The first row should be the header row and will be skipped." + }, + "downloadTemplate": "Download sample CSV template", + "upload": { + "title": "Click or drag CSV file to this area to upload", + "hint": "Only CSV files are supported. Maximum file size is 5MB." + }, + "validation": { + "ready": "File ready for import", + "selected": "Selected file", + "invalidType": "Please select a CSV file", + "tooLarge": "File size must be less than 5MB" + }, + "errors": { + "uploadFailed": "Failed to upload file", + "importFailed": "Import failed", + "importFailedDesc": "Failed to import CSV file. Please try again.", + "noFile": "No file selected", + "noFileDesc": "Please select a CSV file to upload.", + "processingFailed": "Failed to process the CSV file." + }, + "success": { + "imported": "Import complete. {{count}} tasks were created successfully.", + "importedWithSkipped": "Import complete. {{tasksCreated}} tasks were created. {{rowsSkipped}} rows were skipped due to missing title.", + "importComplete": "Import completed", + "importDesc": "Tasks have been imported successfully.", + "processed": "Successfully processed {{processed}} tasks out of {{total}} total.", + "skipped": "{{skipped}} rows were skipped (empty titles)." + } +} diff --git a/worklenz-frontend/public/locales/en/kanban-board.json b/worklenz-frontend/public/locales/en/kanban-board.json index 59b4b2930..7d02d165f 100644 --- a/worklenz-frontend/public/locales/en/kanban-board.json +++ b/worklenz-frontend/public/locales/en/kanban-board.json @@ -2,6 +2,7 @@ "rename": "Rename", "delete": "Delete", "addTask": "Add Task", + "addSubtask": "Add Subtask", "addSectionButton": "Add Section", "changeCategory": "Change category", diff --git a/worklenz-frontend/public/locales/en/project-insights.json b/worklenz-frontend/public/locales/en/project-insights.json new file mode 100644 index 000000000..7f1b7f564 --- /dev/null +++ b/worklenz-frontend/public/locales/en/project-insights.json @@ -0,0 +1,14 @@ +{ + "projectDeadline": "Project Deadline", + "overdueTasksHours": "Overdue tasks (hours)", + "overdueTasks": "Overdue tasks", + "overdueTasksHoursTooltip": "Tasks that has time logged past the end date of the project", + "overdueTasksTooltip": "Tasks that are past the end date of the project", + "projectMembers": "Project Members", + "assigneesWithOverdueTasks": "Assignees with overdue tasks", + "unassignedMembers": "Unassigned Members", + "name": "Name", + "status": "Status", + "dueDate": "Due Date", + "noData": "N/A" +} diff --git a/worklenz-frontend/public/locales/en/project-view-insights.json b/worklenz-frontend/public/locales/en/project-view-insights.json index 1b174a85c..0d2ba5a1e 100644 --- a/worklenz-frontend/public/locales/en/project-view-insights.json +++ b/worklenz-frontend/public/locales/en/project-view-insights.json @@ -1,4 +1,9 @@ { + "segments": { + "overview": "Overview", + "members": "Members", + "tasks": "Tasks" + }, "overview": { "title": "Overview", "statusOverview": "Status Overview", @@ -16,7 +21,14 @@ "completed": "Completed", "incomplete": "Incomplete", "overdue": "Overdue", - "progress": "Progress" + "progress": "Progress", + "status": "Status", + "dueDate": "Due Date", + "lastUpdated": "Last Updated", + "daysOverdue": "Days Overdue", + "completedDate": "Completed Date", + "totalAllocation": "Total Allocation", + "overLoggedTime": "Over Logged Time" }, "tasks": { "overdueTasks": "Overdue Tasks", @@ -36,6 +48,10 @@ "overdueTasksTooltip": "Tasks that are past their due date", "totalLoggedHoursTooltip": "Task estimation and logged time for tasks.", "includeArchivedTasks": "Include Archived Tasks", - "export": "Export" + "export": "Export", + "sortAscending": "Click to sort ascending", + "sortDescending": "Click to sort descending", + "cancelSort": "Click to cancel sorting", + "noData": "N/A" } } 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 000000000..9534e017c --- /dev/null +++ b/worklenz-frontend/public/locales/en/project-view.json @@ -0,0 +1,8 @@ +{ + "tasksList": "Tasks List", + "board": "Board", + "Insights": "Insights", + "Files": "Files", + "Members": "Members", + "Updates": "Updates" +} 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 8100e0683..db5a2168f 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 @@ -1,5 +1,7 @@ { "importTasks": "Import tasks", + "importTask": "Import task", + "importFromCSV": "Import from CSV", "createTask": "Create task", "settings": "Settings", "subscribe": "Subscribe", @@ -9,5 +11,8 @@ "endDate": "End date", "projectSettings": "Project settings", "projectSummary": "Project summary", - "receiveProjectSummary": "Receive a project summary every evening." + "receiveProjectSummary": "Receive a project summary every evening.", + "refreshProject": "Refresh project", + "saveAsTemplate": "Save as template", + "invite": "Invite" } \ No newline at end of file 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 000000000..c7528d63c --- /dev/null +++ b/worklenz-frontend/public/locales/en/settings/teams.json @@ -0,0 +1,10 @@ +{ + "title": "Team", + "titlePlural": "Teams", + "name": "Name", + "created": "Created", + "ownsBy": "Owns By", + "edit": "Edit", + "pinTooltip": "Click to pin this into the main menu", + "page": "page" +} diff --git a/worklenz-frontend/public/locales/es/admin-center/configuration.json b/worklenz-frontend/public/locales/es/admin-center/configuration.json new file mode 100644 index 000000000..7fa410680 --- /dev/null +++ b/worklenz-frontend/public/locales/es/admin-center/configuration.json @@ -0,0 +1,17 @@ +{ + "billingDetails": "Detalles de Facturación", + "companyDetails": "Detalles de la Empresa", + "name": "Nombre", + "emailAddress": "Dirección de Correo Electrónico", + "contactNumber": "Número de Contacto", + "companyName": "Nombre de la Empresa", + "addressLine01": "Línea de Dirección 01", + "addressLine02": "Línea de Dirección 02", + "country": "País", + "city": "Ciudad", + "state": "Estado", + "postalCode": "Código Postal", + "save": "Guardar", + "phoneNumber": "Número de Teléfono", + "phoneNumberValidation": "El número de teléfono debe tener exactamente 10 dígitos" +} diff --git a/worklenz-frontend/public/locales/es/kanban-board.json b/worklenz-frontend/public/locales/es/kanban-board.json index 71de992c6..43d791d1d 100644 --- a/worklenz-frontend/public/locales/es/kanban-board.json +++ b/worklenz-frontend/public/locales/es/kanban-board.json @@ -2,6 +2,7 @@ "rename": "Renombrar", "delete": "Eliminar", "addTask": "Agregar tarea", + "addSubtask": "Agregar Subtarea", "addSectionButton": "Agregar sección", "changeCategory": "Cambiar categoría", diff --git a/worklenz-frontend/public/locales/es/project-insights.json b/worklenz-frontend/public/locales/es/project-insights.json new file mode 100644 index 000000000..272260d9e --- /dev/null +++ b/worklenz-frontend/public/locales/es/project-insights.json @@ -0,0 +1,14 @@ +{ + "projectDeadline": "Fecha Límite del Proyecto", + "overdueTasksHours": "Tareas atrasadas (horas)", + "overdueTasks": "Tareas atrasadas", + "overdueTasksHoursTooltip": "Tareas que tienen tiempo registrado después de la fecha límite del proyecto", + "overdueTasksTooltip": "Tareas que han pasado la fecha límite del proyecto", + "projectMembers": "Miembros del Proyecto", + "assigneesWithOverdueTasks": "Asignados con tareas atrasadas", + "unassignedMembers": "Miembros sin Asignar", + "name": "Nombre", + "status": "Estado", + "dueDate": "Fecha Límite", + "noData": "N/D" +} diff --git a/worklenz-frontend/public/locales/es/project-view-insights.json b/worklenz-frontend/public/locales/es/project-view-insights.json index bd60b58ec..11637e01b 100644 --- a/worklenz-frontend/public/locales/es/project-view-insights.json +++ b/worklenz-frontend/public/locales/es/project-view-insights.json @@ -1,4 +1,9 @@ { + "segments": { + "overview": "Resumen", + "members": "Miembros", + "tasks": "Tareas" + }, "overview": { "title": "Resumen", "statusOverview": "Resumen de Estado", @@ -16,7 +21,14 @@ "completed": "Completadas", "incomplete": "Incompletas", "overdue": "Atrasadas", - "progress": "Progreso" + "progress": "Progreso", + "status": "Estado", + "dueDate": "Fecha Límite", + "lastUpdated": "Última Actualización", + "daysOverdue": "Días de Atraso", + "completedDate": "Fecha de Finalización", + "totalAllocation": "Asignación Total", + "overLoggedTime": "Tiempo Excedido" }, "tasks": { "overdueTasks": "Tareas Atrasadas", @@ -36,6 +48,10 @@ "overdueTasksTooltip": "Tareas que están más allá de su fecha límite", "totalLoggedHoursTooltip": "Estimación de tareas y tiempo registrado para las tareas.", "includeArchivedTasks": "Incluir Tareas Archivadas", - "export": "Exportar" + "export": "Exportar", + "sortAscending": "Clic para ordenar ascendente", + "sortDescending": "Clic para ordenar descendente", + "cancelSort": "Clic para cancelar ordenamiento", + "noData": "N/D" } } 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 000000000..e56918629 --- /dev/null +++ b/worklenz-frontend/public/locales/es/project-view.json @@ -0,0 +1,8 @@ +{ + "tasksList": "Lista de Tareas", + "board": "Tablero", + "Insights": "Perspectivas", + "Files": "Archivos", + "Members": "Miembros", + "Updates": "Actualizaciones" +} 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 de6020cf1..fd7684698 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 @@ -9,5 +9,8 @@ "endDate": "Fecha de finalización", "projectSettings": "Ajustes del proyecto", "projectSummary": "Resumen del proyecto", - "receiveProjectSummary": "Recibir un resumen del proyecto todas las noches." + "receiveProjectSummary": "Recibir un resumen del proyecto todas las noches.", + "refreshProject": "Actualizar proyecto", + "saveAsTemplate": "Guardar como plantilla", + "invite": "Invitar" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/reporting-sidebar.json b/worklenz-frontend/public/locales/es/reporting-sidebar.json index d5e89788a..fea955ef9 100644 --- a/worklenz-frontend/public/locales/es/reporting-sidebar.json +++ b/worklenz-frontend/public/locales/es/reporting-sidebar.json @@ -1,8 +1,8 @@ { - "overviewText": "Resumen", - "projectsText": "Proyectos", - "membersText": "Miembros", - "timeReportsText": "Informes de Tiempo", - "estimateVsActualText": "Estimado vs Real", + "overview": "Resumen", + "projects": "Proyectos", + "members": "Miembros", + "timeReports": "Informes de Tiempo", + "estimateVsActual": "Estimado vs Real", "currentOrganizationTooltip": "Organización actual" } diff --git a/worklenz-frontend/public/locales/es/settings/categories.json b/worklenz-frontend/public/locales/es/settings/categories.json index 417e17dd7..25ffdf2f9 100644 --- a/worklenz-frontend/public/locales/es/settings/categories.json +++ b/worklenz-frontend/public/locales/es/settings/categories.json @@ -1,10 +1,10 @@ { - "categoryColumn": "Category", - "deleteConfirmationTitle": "Are you sure?", - "deleteConfirmationOk": "Yes", - "deleteConfirmationCancel": "Cancel", - "associatedTaskColumn": "Associated Task", - "searchPlaceholder": "Search by name", - "emptyText": "Categories can be created while updating or creating projects.", - "colorChangeTooltip": "Click to change color" + "categoryColumn": "Categoría", + "deleteConfirmationTitle": "¿Estás seguro?", + "deleteConfirmationOk": "Sí", + "deleteConfirmationCancel": "Cancelar", + "associatedTaskColumn": "Tareas Asociadas", + "searchPlaceholder": "Buscar por nombre", + "emptyText": "Las categorías se pueden crear al actualizar o crear proyectos.", + "colorChangeTooltip": "Haz clic para cambiar el color" } 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 000000000..0347cc6d8 --- /dev/null +++ b/worklenz-frontend/public/locales/es/settings/teams.json @@ -0,0 +1,10 @@ +{ + "title": "Equipo", + "titlePlural": "Equipos", + "name": "Nombre", + "created": "Creado", + "ownsBy": "Propietario", + "edit": "Editar", + "pinTooltip": "Haga clic para fijar esto en el menú principal", + "page": "página" +} diff --git a/worklenz-frontend/public/locales/nl/common.json b/worklenz-frontend/public/locales/nl/common.json new file mode 100644 index 000000000..e69de29bb diff --git a/worklenz-frontend/public/locales/nl/project-view.json b/worklenz-frontend/public/locales/nl/project-view.json new file mode 100644 index 000000000..d88358bbe --- /dev/null +++ b/worklenz-frontend/public/locales/nl/project-view.json @@ -0,0 +1,8 @@ +{ + "tasksList": "Takenlijst", + "board": "Bord", + "Insights": "Inzichten", + "Files": "Bestanden", + "Members": "Leden", + "Updates": "Updates" +} diff --git a/worklenz-frontend/public/locales/nl/settings/teams.json b/worklenz-frontend/public/locales/nl/settings/teams.json new file mode 100644 index 000000000..ba978aaf6 --- /dev/null +++ b/worklenz-frontend/public/locales/nl/settings/teams.json @@ -0,0 +1,10 @@ +{ + "title": "Team", + "titlePlural": "Teams", + "name": "Naam", + "created": "Aangemaakt", + "ownsBy": "Eigenaar", + "edit": "Bewerken", + "pinTooltip": "Klik om dit vast te zetten in het hoofdmenu", + "page": "pagina" +} diff --git a/worklenz-frontend/public/locales/pt/common.json b/worklenz-frontend/public/locales/pt/common.json index ce540a28d..e301248b8 100644 --- a/worklenz-frontend/public/locales/pt/common.json +++ b/worklenz-frontend/public/locales/pt/common.json @@ -5,5 +5,11 @@ "signup-failed": "Falha no cadastro. Por favor, certifique-se de que todos os campos obrigatórios estão preenchidos e tente novamente.", "reconnecting": "Reconectando ao servidor...", "connection-lost": "Conexão perdida. Tentando reconectar...", - "connection-restored": "Conexão restaurada. Reconectando ao servidor..." + "connection-restored": "Conexão restaurada. Reconectando ao servidor...", + "timer": { + "conflictTitle": "Timer Já Executando", + "conflictMessage": "Você tem um timer ativo executando em \"{{taskName}}\". Deseja pará-lo e iniciar um novo timer nesta tarefa?", + "stopAndStartNew": "Parar e Iniciar Novo", + "cancel": "Cancelar" + } } diff --git a/worklenz-frontend/public/locales/pt/project-insights.json b/worklenz-frontend/public/locales/pt/project-insights.json new file mode 100644 index 000000000..1e3c2eac7 --- /dev/null +++ b/worklenz-frontend/public/locales/pt/project-insights.json @@ -0,0 +1,10 @@ +{ + "projectDeadline": "Prazo do Projeto", + "overdueTasksHours": "Tarefas atrasadas (horas)", + "overdueTasks": "Tarefas atrasadas", + "overdueTasksHoursTooltip": "Tarefas que têm tempo registrado após a data final do projeto", + "overdueTasksTooltip": "Tarefas que passaram da data final do projeto", + "projectMembers": "Membros do Projeto", + "assigneesWithOverdueTasks": "Responsáveis com tarefas atrasadas", + "unassignedMembers": "Membros não atribuídos" +} diff --git a/worklenz-frontend/public/locales/pt/project-view-insights.json b/worklenz-frontend/public/locales/pt/project-view-insights.json index 2ad6ee924..e641ab022 100644 --- a/worklenz-frontend/public/locales/pt/project-view-insights.json +++ b/worklenz-frontend/public/locales/pt/project-view-insights.json @@ -1,9 +1,15 @@ { + "segments": { + "overview": "Visão Geral", + "members": "Membros", + "tasks": "Tarefas" + }, "overview": { "title": "Visão Geral", "statusOverview": "Visão Geral do Status", "priorityOverview": "Visão Geral da Prioridade", - "lastUpdatedTasks": "Últimas Tarefas Atualizadas" + "lastUpdatedTasks": "Últimas Tarefas Atualizadas", + "ProjectDeadline" : "Prazo do Projeto" }, "members": { "title": "Membros", @@ -16,7 +22,13 @@ "completed": "Concluído", "incomplete": "Incompleto", "overdue": "Atrasado", - "progress": "Progresso" + "progress": "Progresso", + "status": "Status", + "dueDate": "Data de Vencimento", + "daysOverdue": "Dias de Atraso", + "completedDate": "Data de Conclusão", + "totalAllocation": "Alocação Total", + "overLoggedTime": "Tempo Registrado Excedente" }, "tasks": { "overdueTasks": "Tarefas Atrasadas", @@ -36,6 +48,9 @@ "overdueTasksTooltip": "Tarefas que estão atrasadas", "totalLoggedHoursTooltip": "Estimativa de tarefas e tempo registrado.", "includeArchivedTasks": "Incluir Tarefas Arquivadas", - "export": "Exportar" + "export": "Exportar", + "sortAscending": "Clique para ordenar crescente", + "sortDescending": "Clique para ordenar decrescente", + "cancelSort": "Clique para cancelar ordenação" } } 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 000000000..b58d7fc03 --- /dev/null +++ b/worklenz-frontend/public/locales/pt/project-view.json @@ -0,0 +1,9 @@ +{ + "tasksList": "Lista de Tarefas", + "board": "Quadro", + "Insights": "Insights", + "Files": "Arquivos", + "Members": "Membros", + "Updates": "Atualizações", + "activityLogs": "Registros de Atividade" +} \ 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 194668eba..3cf3ce685 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 @@ -9,5 +9,8 @@ "endDate": "Data de fim", "projectSettings": "Configurações do projeto", "projectSummary": "Resumo do projeto", - "receiveProjectSummary": "Receber um resumo do projeto todas as noites." + "receiveProjectSummary": "Receber um resumo do projeto todas as noites.", + "refreshProject": "Atualizar projeto", + "saveAsTemplate": "Salvar como modelo", + "invite": "Convidar" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/reporting-members.json b/worklenz-frontend/public/locales/pt/reporting-members.json index a8035dcd5..6d037301b 100644 --- a/worklenz-frontend/public/locales/pt/reporting-members.json +++ b/worklenz-frontend/public/locales/pt/reporting-members.json @@ -11,23 +11,23 @@ "EndDateInputPlaceholder": "End date", "filterButton": "Filter", - "membersTitle": "Members", - "includeArchivedButton": "Include Archived Projects", - "exportButton": "Export", + "membersTitle": "Membros", + "includeArchivedButton": "Incluir projetos arquivados", + "exportButton": "Exportar", "excelButton": "Excel", - "searchByNameInputPlaceholder": "Search by name", + "searchByNameInputPlaceholder": "Pesquisar por nome", - "memberColumn": "Member", - "tasksProgressColumn": "Tasks Progress", - "tasksAssignedColumn": "Tasks Assigned", - "completedTasksColumn": "Completed Tasks", - "overdueTasksColumn": "Overdue Tasks", - "ongoingTasksColumn": "Ongoing Tasks", + "memberColumn": "Membro", + "tasksProgressColumn": "Progresso das tarefas", + "tasksAssignedColumn": "Tarefas atribuídas", + "completedTasksColumn": "Tarefas concluídas", + "overdueTasksColumn": "Tarefas atrasadas", + "ongoingTasksColumn": "Tarefas em andamento", - "tasksAssignedColumnTooltip": "Tasks assigned on selected date range", - "overdueTasksColumnTooltip": "Tasks overdue for end of the selected date range", - "completedTasksColumnTooltip": "Tasks completed on selected date range", - "ongoingTasksColumnTooltip": "Started tasks not completed yet", + "tasksAssignedColumnTooltip": "Tarefas atribuídas no período selecionado", + "overdueTasksColumnTooltip": "Tarefas atrasadas no final do período selecionado", + "completedTasksColumnTooltip": "Tarefas concluídas no período selecionado", + "ongoingTasksColumnTooltip": "Tarefas iniciadas ainda não concluídas", "todoText": "To Do", "doingText": "Doing", diff --git a/worklenz-frontend/public/locales/pt/reporting-sidebar.json b/worklenz-frontend/public/locales/pt/reporting-sidebar.json index e09940f31..80c404c0d 100644 --- a/worklenz-frontend/public/locales/pt/reporting-sidebar.json +++ b/worklenz-frontend/public/locales/pt/reporting-sidebar.json @@ -1,8 +1,8 @@ { - "overviewText": "Visão Geral", - "projectsText": "Projetos", - "membersText": "Membros", - "timeReportsText": "Relatórios de Tempo", - "estimateVsActualText": "Estimado Vs Real", + "overview": "Visão Geral", + "projects": "Projetos", + "members": "Membros", + "timeReports": "Relatórios de Tempo", + "estimateVsActual": "Estimado Vs Real", "currentOrganizationTooltip": "Organização Atual" } diff --git a/worklenz-frontend/src/components/admin-center/configuration/configuration.tsx b/worklenz-frontend/src/components/admin-center/configuration/configuration.tsx index a9a24e24c..92cf4c60a 100644 --- a/worklenz-frontend/src/components/admin-center/configuration/configuration.tsx +++ b/worklenz-frontend/src/components/admin-center/configuration/configuration.tsx @@ -1,5 +1,6 @@ import { Button, Card, Col, Divider, Form, Input, notification, Row, Select } from 'antd'; import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { RootState } from '../../../app/store'; import { useAppSelector } from '@/hooks/useAppSelector'; import { IBillingConfigurationCountry } from '@/types/admin-center/country.types'; @@ -8,6 +9,7 @@ import { IBillingConfiguration } from '@/types/admin-center/admin-center.types'; import logger from '@/utils/errorLogger'; const Configuration: React.FC = () => { + const { t } = useTranslation('admin-center/configuration'); const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode); const [countries, setCountries] = useState([]); @@ -70,7 +72,7 @@ const Configuration: React.FC = () => { gap: '4px', }} > - Billing Details + {t('billingDetails')} } style={{ marginTop: '16px' }} @@ -84,7 +86,7 @@ const Configuration: React.FC = () => { { }, ]} > - + { }, ]} > - + { const input = e.target as HTMLInputElement; // Type assertion to access 'value' @@ -143,24 +145,24 @@ const Configuration: React.FC = () => { gap: '4px', }} > - Company Details + {t('companyDetails')} - - + + - - + + - - + + @@ -173,12 +175,12 @@ const Configuration: React.FC = () => { scrollbarColor: 'red', }} > - + + + - - + + - - + + @@ -208,7 +210,7 @@ const Configuration: React.FC = () => { diff --git a/worklenz-frontend/src/components/csv-import/csv-import-modal.css b/worklenz-frontend/src/components/csv-import/csv-import-modal.css new file mode 100644 index 000000000..2a9467b5f --- /dev/null +++ b/worklenz-frontend/src/components/csv-import/csv-import-modal.css @@ -0,0 +1,35 @@ +/* CSV Import Modal Styles */ +.csv-import-modal .ant-upload-drag { + border: 2px dashed #d9d9d9; + border-radius: 8px; + transition: border-color 0.3s ease; +} + +.csv-import-modal .ant-upload-drag:hover { + border-color: #40a9ff; +} + +.csv-import-modal .ant-upload-drag-icon { + margin-bottom: 16px; +} + +.csv-import-modal .ant-upload-text { + font-size: 16px; + font-weight: 500; + color: #262626; + margin-bottom: 8px; +} + +.csv-import-modal .ant-upload-hint { + color: #8c8c8c; + font-size: 14px; +} + +.csv-import-modal .ant-alert { + border-radius: 8px; +} + +.csv-import-modal .ant-btn-link { + color: #1890ff; + font-weight: 500; +} diff --git a/worklenz-frontend/src/components/csv-import/csv-import-modal.tsx b/worklenz-frontend/src/components/csv-import/csv-import-modal.tsx new file mode 100644 index 000000000..6a7a5637d --- /dev/null +++ b/worklenz-frontend/src/components/csv-import/csv-import-modal.tsx @@ -0,0 +1,305 @@ +import React, { useState } from 'react'; +import { Modal, Upload, Button, Typography, Divider, Alert, Space, notification } from 'antd'; +import { UploadOutlined, DownloadOutlined, InboxOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import type { UploadFile, UploadProps } from 'antd/es/upload/interface'; +import apiClient, { getCsrfToken, refreshCsrfToken } from '../../api/api-client'; +import './csv-import-modal.css'; + +const { Title, Text, Paragraph } = Typography; +const { Dragger } = Upload; + +interface CSVImportModalProps { + visible: boolean; + projectId: string; + onClose: () => void; + onImportComplete: () => void; +} + +const CSVImportModal: React.FC = ({ + visible, + projectId, + onClose, + onImportComplete, +}) => { + const { t } = useTranslation('csv-import'); + const [fileList, setFileList] = useState([]); //Stores the uploaded file. + const [uploading, setUploading] = useState(false); //Boolean to show a loading spinner. + const [uploadResult, setUploadResult] = useState<{ // Holds results of the import (e.g., how many tasks were added/skipped). + success: boolean; + processed: number; + skipped: number; + total: number; + errors: string[]; + } | null>(null); + + //Download Sample CSV Template + const downloadTemplate = () => { + const csvContent = 'Task Title,Description\nSample Task 1,This is a sample task description\nSample Task 2,Another sample description\nTask with empty description,\nImportant Task,This task needs immediate attention'; + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', 'import-template.csv'); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + //Validate File Before Upload + const beforeUpload: UploadProps['beforeUpload'] = (file) => { + const isCSV = file.type === 'text/csv' || file.name?.toLowerCase().endsWith('.csv'); //File must be .csv + if (!isCSV) { + return false; + } + + const isLt5M = file.size / 1024 / 1024 < 5; //File must be under 5MB + if (!isLt5M) { + return false; + } + + setFileList([file]); + return false; // Don't auto upload + }; + + //This function is triggered when the user clicks "Import Tasks". + const handleImport = async () => { + if (fileList.length === 0) { + return; + } + + setUploading(true); + setUploadResult(null); + + try { + // Ensure we have a CSRF token before making the request + let csrfToken = getCsrfToken(); + if (!csrfToken) { + console.log('No CSRF token found, refreshing...'); + csrfToken = await refreshCsrfToken(); + if (!csrfToken) { + throw new Error('Failed to obtain CSRF token'); + } + } + + // Create FormData and append the CSV file + const formData = new FormData(); //Prepares the uploaded file using FormData. + formData.append('csvFile', fileList[0] as File); + + const response = await apiClient.post(`/api/v1/tasks/import-csv/${projectId}`, formData, { //Sends it to the backend endpoint (/import-csv/:projectId). + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + const result = response.data; + + //On Successful Import + if (result.done && result.body) { + setUploadResult({ + success: true, + processed: result.body.processed, + skipped: result.body.skipped, + total: result.body.total, + errors: result.body.errors || [], + }); + notification.success({ + message: t("success.importComplete", "Import completed"), //Shows success notification. + description: `Successfully imported ${result.body.processed} tasks out of ${result.body.total} total.`, + }); + + // Auto-close modal after successful import + setTimeout(() => { + handleClose(); + onImportComplete(); + }, 2000); //Auto-closes modal after 2 seconds and notifies parent component. + } else { + throw new Error(result.message || "Import failed"); + } + } catch (error: any) { + console.error("Import error:", error); + + // Handle different types of errors with more specific messages + let errorMessage = "Failed to import CSV file. Please try again."; + + if (error.response?.status === 401) { + errorMessage = "Authentication required. Please log in again."; + } else if (error.response?.status === 403) { + errorMessage = "Access denied. You don't have permission to import CSV files."; + } else if (error.response?.status === 404) { + errorMessage = "CSV import feature is not available or the project was not found."; + } else if (error.response?.data?.message) { + errorMessage = error.response.data.message; + } else if (error.message === 'Failed to obtain CSRF token') { + errorMessage = "Security token error. Please refresh the page and try again."; + } else if (error.message) { + errorMessage = error.message; + } + + notification.error({ + message: t("errors.importFailed", "Import failed"), + description: errorMessage, + }); + + setUploadResult({ + success: false, + processed: 0, + skipped: 0, + total: 0, + errors: [errorMessage], + }); + } finally { + setUploading(false); //Stops the loading spinner, no matter success or error. + } + }; + + const handleClose = () => { //handleClose() resets everything and calls the parent onClose(). + setFileList([]); + setUploading(false); + onClose(); + }; + + const removeFile = () => { //removeFile() clears the uploaded file. + setFileList([]); + }; + + return ( + + + {t('title', 'Import Tasks from CSV')} + + } + open={visible} + onCancel={handleClose} + width={600} + className="csv-import-modal" + footer={[ + , + , + ]} + > +
+ {/* Instructions */} + + + {t('instructions.upload', 'Upload a CSV file to import tasks.')} + + + {t('instructions.format', 'The file must contain exactly two columns in this order: Task Title and Description.')} + + + {t('instructions.header', 'The first row should be the header row and will be skipped.')} + +
+ } + type="info" + showIcon + style={{ marginBottom: 16 }} + /> + + {/* Template Download */} +
+ +
+ + + + {/* Import Results */} + {uploadResult && ( +
+ + {uploadResult.success ? ( +
+

{t('success.processed', `Successfully processed ${uploadResult.processed} tasks out of ${uploadResult.total} total.`)}

+ {uploadResult.skipped > 0 && ( +

{t('success.skipped', `${uploadResult.skipped} rows were skipped (empty titles).`)}

+ )} +
+ ) : ( +
+

{t('errors.processingFailed', 'Failed to process the CSV file.')}

+ {uploadResult.errors.length > 0 && ( +
    + {uploadResult.errors.slice(0, 3).map((error, index) => ( +
  • {error}
  • + ))} + {uploadResult.errors.length > 3 && ( +
  • ...and {uploadResult.errors.length - 3} more errors
  • + )} +
+ )} +
+ )} +
+ } + type={uploadResult.success ? 'success' : 'error'} + showIcon + style={{ marginBottom: 16 }} + /> + + )} + + {/* File Upload */} + +

+ +

+

+ {t('upload.title', 'Click or drag CSV file to this area to upload')} +

+

+ {t('upload.hint', 'Only CSV files are supported. Maximum file size is 5MB.')} +

+
+ + {/* File validation messages */} + {fileList.length > 0 && ( + + )} + +
+ ); +}; + +export default CSVImportModal; diff --git a/worklenz-frontend/src/features/tasks/tasks.slice.ts b/worklenz-frontend/src/features/tasks/tasks.slice.ts index dbc2f955f..976de9277 100644 --- a/worklenz-frontend/src/features/tasks/tasks.slice.ts +++ b/worklenz-frontend/src/features/tasks/tasks.slice.ts @@ -74,7 +74,7 @@ interface ITaskState { labels: ITaskLabelFilter[]; priorities: string[]; members: string[]; - activeTimers: Record; + activeTimers: Record; convertToSubtaskDrawerOpen: boolean; customColumns: ITaskListColumn[]; customColumnValues: Record>; @@ -105,6 +105,29 @@ const initialState: ITaskState = { customColumnValues: {}, }; +// Helper function to sync activeTimers from task data +const syncActiveTimersFromTasks = (state: ITaskState, userId: string) => { + const newActiveTimers: Record = {}; + + for (const group of state.taskGroups) { + for (const task of group.tasks) { + if (task.timer_start_time && task.id) { + newActiveTimers[task.id] = { userId, startTime: task.timer_start_time }; + } + + if (task.sub_tasks) { + for (const subTask of task.sub_tasks) { + if (subTask.timer_start_time && subTask.id) { + newActiveTimers[subTask.id] = { userId, startTime: subTask.timer_start_time }; + } + } + } + } + } + + state.activeTimers = newActiveTimers; +}; + export const COLUMN_KEYS = { KEY: 'KEY', NAME: 'NAME', @@ -135,8 +158,8 @@ export const fetchTaskGroups = createAsyncThunk( 'tasks/fetchTaskGroups', async (projectId: string, { rejectWithValue, getState }) => { try { - const state = getState() as { taskReducer: ITaskState }; - const { taskReducer } = state; + const state = getState() as { taskReducer: ITaskState; userReducer: { id: string } }; + const { taskReducer, userReducer } = state; const selectedMembers = taskReducer.taskAssignees .filter(member => member.selected) @@ -164,7 +187,10 @@ export const fetchTaskGroups = createAsyncThunk( }; const response = await tasksApiService.getTaskList(config); - return response.body; + return { + taskGroups: response.body, + userId: userReducer.id + }; } catch (error) { logger.error('Fetch Task Groups', error); if (error instanceof Error) { @@ -181,8 +207,8 @@ export const fetchSubTasks = createAsyncThunk( { taskId, projectId }: { taskId: string; projectId: string }, { rejectWithValue, getState, dispatch } ) => { - const state = getState() as { taskReducer: ITaskState }; - const { taskReducer } = state; + const state = getState() as { taskReducer: ITaskState; userReducer: { id: string } }; + const { taskReducer, userReducer } = state; // Check if the task is already expanded const task = taskReducer.taskGroups.flatMap(group => group.tasks).find(t => t.id === taskId); @@ -223,7 +249,10 @@ export const fetchSubTasks = createAsyncThunk( if (response.body.length > 0) { dispatch(toggleTaskRowExpansion(taskId)); } - return response.body; + return { + subTasks: response.body as IProjectTask[], + userId: userReducer.id + }; } catch (error) { logger.error('Fetch Sub Tasks', error); if (error instanceof Error) { @@ -770,10 +799,23 @@ const taskSlice = createSlice({ action: PayloadAction<{ taskId: string; timeTracking: number | null; + userId?: string; }> ) => { - const { taskId, timeTracking } = action.payload; - state.activeTimers[taskId] = timeTracking; + const { taskId, timeTracking, userId } = action.payload; + if (timeTracking && userId) { + state.activeTimers[taskId] = { userId, startTime: timeTracking }; + } else { + state.activeTimers[taskId] = null; + } + }, + + syncActiveTimersFromTaskData: ( + state, + action: PayloadAction<{ userId: string }> + ) => { + const { userId } = action.payload; + syncActiveTimersFromTasks(state, userId); }, updateTaskPriority: (state, action: PayloadAction) => { @@ -985,7 +1027,11 @@ const taskSlice = createSlice({ }) .addCase(fetchTaskGroups.fulfilled, (state, action) => { state.loadingGroups = false; - state.taskGroups = action.payload; + state.taskGroups = action.payload.taskGroups; + // Sync activeTimers from loaded task data + if (action.payload.userId) { + syncActiveTimersFromTasks(state, action.payload.userId); + } }) .addCase(fetchTaskGroups.rejected, (state, action) => { state.loadingGroups = false; @@ -994,20 +1040,30 @@ const taskSlice = createSlice({ .addCase(fetchSubTasks.pending, state => { state.error = null; }) - .addCase(fetchSubTasks.fulfilled, (state, action: PayloadAction) => { - if (action.payload.length > 0) { - const taskId = action.payload[0].parent_task_id; + .addCase(fetchSubTasks.fulfilled, (state, action) => { + const payload = action.payload; + // Handle both old array format and new object format + const subTasks = Array.isArray(payload) ? payload : payload.subTasks; + const userId = Array.isArray(payload) ? null : payload.userId; + + if (subTasks.length > 0) { + const taskId = subTasks[0].parent_task_id; if (taskId) { for (const group of state.taskGroups) { const task = group.tasks.find(t => t.id === taskId); if (task) { - task.sub_tasks = action.payload; + task.sub_tasks = subTasks; task.show_sub_tasks = true; break; } } } } + + // Sync activeTimers if userId is available + if (userId) { + syncActiveTimersFromTasks(state, userId); + } }) .addCase(fetchSubTasks.rejected, (state, action) => { state.error = action.error.message || 'Failed to fetch sub tasks'; @@ -1121,6 +1177,7 @@ export const { updateTaskStartDate, updateTaskEstimation, updateTaskTimeTracking, + syncActiveTimersFromTaskData, toggleTaskRowExpansion, resetTaskListData, updateTaskStatusColor, diff --git a/worklenz-frontend/src/hooks/useTaskTimer.ts b/worklenz-frontend/src/hooks/useTaskTimer.ts index d61876779..f378b0daa 100644 --- a/worklenz-frontend/src/hooks/useTaskTimer.ts +++ b/worklenz-frontend/src/hooks/useTaskTimer.ts @@ -1,26 +1,109 @@ import { useState, useEffect, useCallback, useRef } from 'react'; +import { Modal } from 'antd'; +import { useTranslation } from 'react-i18next'; import { buildTimeString } from '@/utils/timeUtils'; import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import logger from '@/utils/errorLogger'; import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { updateTaskTimeTracking } from '@/features/tasks/tasks.slice'; +import { updateTaskTimeTracking, syncActiveTimersFromTaskData } from '@/features/tasks/tasks.slice'; import { useAppSelector } from '@/hooks/useAppSelector'; export const useTaskTimer = (taskId: string, initialStartTime: number | null) => { const dispatch = useAppDispatch(); const { socket } = useSocket(); + const { t } = useTranslation('common'); const DEFAULT_TIME_LEFT = buildTimeString(0, 0, 0); const intervalRef = useRef(null); const hasInitialized = useRef(false); // Track if we've initialized const activeTimers = useAppSelector(state => state.taskReducer.activeTimers); - const reduxStartTime = activeTimers[taskId]; + const taskGroups = useAppSelector(state => state.taskReducer.taskGroups); + const currentUser = useAppSelector(state => state.userReducer); + const reduxStartTime = activeTimers[taskId]?.startTime || null; const started = Boolean(reduxStartTime); const [timeString, setTimeString] = useState(DEFAULT_TIME_LEFT); const [localStarted, setLocalStarted] = useState(false); + // Utility function to find task by ID and get its name + const getTaskName = useCallback((searchTaskId: string): string => { + for (const group of taskGroups) { + // Check main tasks + const task = group.tasks.find(t => t.id === searchTaskId); + if (task) { + return task.name || 'Unknown Task'; + } + + // Check subtasks + for (const task of group.tasks) { + if (task.sub_tasks) { + const subTask = task.sub_tasks.find(subtask => subtask.id === searchTaskId); + if (subTask) { + return subTask.name || 'Unknown Task'; + } + } + } + } + return 'Unknown Task'; + }, [taskGroups]); + + // Check for active timer for the current user (should only allow one running timer) + const getActiveTimerInfo = useCallback(() => { + // Debug: Log current user and active timers + console.log('Checking timer conflict for user:', currentUser.id); + console.log('Active timers:', activeTimers); + + // Check activeTimers for any timer belonging to the current user (except current task) + for (const [timerTaskId, timerInfo] of Object.entries(activeTimers)) { + console.log(`Checking timer ${timerTaskId}:`, timerInfo); + if (timerInfo && timerInfo.userId === currentUser.id && timerTaskId !== taskId) { + console.log(`Found conflict: timer ${timerTaskId} belongs to current user ${currentUser.id}`); + // Find the task name + const taskName = getTaskName(timerTaskId); + return { taskId: timerTaskId, taskName }; + } + } + console.log('No conflict found for user:', currentUser.id); + return null; + }, [activeTimers, currentUser.id, taskId, getTaskName]); + + // start a timer with conflict check + const startTimerWithConflictCheck = useCallback(() => { + const activeTimerInfo = getActiveTimerInfo(); + + if (activeTimerInfo) { + // Show confirmation dialog - user already has a timer running + Modal.confirm({ + title: t('timer.conflictTitle'), + content: t('timer.conflictMessage', { taskName: activeTimerInfo.taskName }), + okText: t('timer.stopAndStartNew'), + cancelText: t('timer.cancel'), + okType: 'primary', + onOk: () => { + // Stop the current timer first + dispatch(updateTaskTimeTracking({ taskId: activeTimerInfo.taskId, timeTracking: null })); + socket?.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: activeTimerInfo.taskId })); + + // Then start the new timer + const now = Date.now(); + dispatch(updateTaskTimeTracking({ taskId, timeTracking: now, userId: currentUser.id })); + setLocalStarted(true); + socket?.emit(SocketEvents.TASK_TIMER_START.toString(), JSON.stringify({ task_id: taskId })); + }, + onCancel: () => { + // Do nothing, keep the current timer running + }, + }); + } else { + // No conflict, start timer normally + const now = Date.now(); + dispatch(updateTaskTimeTracking({ taskId, timeTracking: now, userId: currentUser.id })); + setLocalStarted(true); + socket?.emit(SocketEvents.TASK_TIMER_START.toString(), JSON.stringify({ task_id: taskId })); + } + }, [getActiveTimerInfo, taskId, dispatch, socket, t, currentUser.id]); + const timerTick = useCallback(() => { if (!reduxStartTime) return; const now = Date.now(); @@ -74,27 +157,23 @@ export const useTaskTimer = (taskId: string, initialStartTime: number | null) => useEffect(() => { if (!hasInitialized.current && initialStartTime && reduxStartTime === undefined) { - dispatch(updateTaskTimeTracking({ taskId, timeTracking: initialStartTime })); + dispatch(updateTaskTimeTracking({ taskId, timeTracking: initialStartTime, userId: currentUser.id })); setLocalStarted(true); } else if (reduxStartTime && !localStarted) { setLocalStarted(true); } hasInitialized.current = true; // Mark as initialized - }, [initialStartTime, reduxStartTime, taskId, dispatch]); + }, [initialStartTime, reduxStartTime, taskId, dispatch, currentUser.id]); const handleStartTimer = useCallback(() => { if (started || !taskId) return; try { - const now = Date.now(); - - dispatch(updateTaskTimeTracking({ taskId, timeTracking: now })); - setLocalStarted(true); - socket?.emit(SocketEvents.TASK_TIMER_START.toString(), JSON.stringify({ task_id: taskId })); + startTimerWithConflictCheck(); } catch (error) { logger.error('Error starting timer:', error); } - }, [taskId, started, socket, dispatch]); + }, [taskId, started, startTimerWithConflictCheck]); const handleStopTimer = useCallback(() => { if (!taskId) return; @@ -127,7 +206,7 @@ export const useTaskTimer = (taskId: string, initialStartTime: number | null) => if (task_id === taskId && start_time) { const time = typeof start_time === 'number' ? start_time : parseInt(start_time); - dispatch(updateTaskTimeTracking({ taskId, timeTracking: time })); + dispatch(updateTaskTimeTracking({ taskId, timeTracking: time, userId: currentUser.id })); setLocalStarted(true); } } catch (error) { @@ -142,7 +221,7 @@ export const useTaskTimer = (taskId: string, initialStartTime: number | null) => socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); }; - }, [socket, taskId, dispatch, resetTimer]); + }, [socket, taskId, dispatch, resetTimer, currentUser.id]); return { started, diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index fc4b8e87b..f2101f373 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -20,14 +20,14 @@ export const tabItems: TabItems[] = [ { index: 0, key: 'tasks-list', - label: 'Task List', + label: 'tasksList', isPinned: true, element: React.createElement(ProjectViewTaskList), }, { index: 1, key: 'board', - label: 'Board', + label: 'board', isPinned: true, element: React.createElement(ProjectViewBoard), }, diff --git a/worklenz-frontend/src/pages/projects/projectView/insights/insights-members/tables/assigned-tasks-list.tsx b/worklenz-frontend/src/pages/projects/projectView/insights/insights-members/tables/assigned-tasks-list.tsx index d78f9797e..0adc38964 100644 --- a/worklenz-frontend/src/pages/projects/projectView/insights/insights-members/tables/assigned-tasks-list.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/insights/insights-members/tables/assigned-tasks-list.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import { Flex, Tooltip, Typography } from 'antd'; +import { useTranslation } from 'react-i18next'; import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service'; import { useAppSelector } from '@/hooks/useAppSelector'; @@ -14,26 +15,27 @@ interface AssignedTasksListTableProps { archived: boolean; } -const columnsList = [ - { key: 'name', columnHeader: 'Name', width: 280 }, - { key: 'status', columnHeader: 'Status', width: 100 }, - { key: 'dueDate', columnHeader: 'Due Date', width: 150 }, - { key: 'overdue', columnHeader: 'Days Overdue', width: 150 }, - { key: 'completedDate', columnHeader: 'Completed Date', width: 150 }, - { key: 'totalAllocation', columnHeader: 'Total Allocation', width: 150 }, - { key: 'overLoggedTime', columnHeader: 'Over Logged Time', width: 150 }, -]; - const AssignedTasksListTable: React.FC = ({ memberId, projectId, archived, }) => { + const { t } = useTranslation('project-view-insights'); const [memberTasks, setMemberTasks] = useState([]); const [loading, setLoading] = useState(true); const themeMode = useAppSelector(state => state.themeReducer.mode); + const columnsList = [ + { key: 'name', columnHeader: t('members.name'), width: 280 }, + { key: 'status', columnHeader: t('members.status'), width: 100 }, + { key: 'dueDate', columnHeader: t('members.dueDate'), width: 150 }, + { key: 'overdue', columnHeader: t('members.daysOverdue'), width: 150 }, + { key: 'completedDate', columnHeader: t('members.completedDate'), width: 150 }, + { key: 'totalAllocation', columnHeader: t('members.totalAllocation'), width: 150 }, + { key: 'overLoggedTime', columnHeader: t('members.overLoggedTime'), width: 150 }, + ]; + useEffect(() => { const getTasksByMemberId = async () => { setLoading(true); diff --git a/worklenz-frontend/src/pages/projects/projectView/insights/insights-overview/tables/last-updated-tasks.tsx b/worklenz-frontend/src/pages/projects/projectView/insights/insights-overview/tables/last-updated-tasks.tsx index 05a328600..d2b561e95 100644 --- a/worklenz-frontend/src/pages/projects/projectView/insights/insights-overview/tables/last-updated-tasks.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/insights/insights-overview/tables/last-updated-tasks.tsx @@ -1,5 +1,6 @@ import { Flex, Table, Tooltip, Typography } from 'antd'; import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { colors } from '@/styles/colors'; import { TableProps } from 'antd/lib'; import { simpleDateFormat } from '@/utils/simpleDateFormat'; @@ -11,6 +12,7 @@ import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale'; import { calculateTimeDifference } from '@/utils/calculate-time-difference'; const LastUpdatedTasks = () => { + const { t } = useTranslation('project-view-insights'); const themeMode = useAppSelector(state => state.themeReducer.mode); const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer); @@ -45,12 +47,12 @@ const LastUpdatedTasks = () => { const columns: TableProps['columns'] = [ { key: 'name', - title: 'Name', + title: t('members.name'), render: (record: IInsightTasks) => {record.name}, }, { key: 'status', - title: 'Status', + title: t('members.status'), render: (record: IInsightTasks) => ( { }, { key: 'dueDate', - title: 'Due Date', + title: t('members.dueDate'), render: (record: IInsightTasks) => ( - {record.end_date ? simpleDateFormat(record.end_date) : 'N/A'} + {record.end_date ? simpleDateFormat(record.end_date) : t('common.noData')} ), }, { key: 'lastUpdated', - title: 'Last Updated', + title: t('members.lastUpdated'), render: (record: IInsightTasks) => ( - + - {record.updated_at ? calculateTimeDifference(record.updated_at) : 'N/A'} + {record.updated_at ? calculateTimeDifference(record.updated_at) : t('common.noData')} ), diff --git a/worklenz-frontend/src/pages/projects/projectView/insights/insights-overview/tables/project-deadline.tsx b/worklenz-frontend/src/pages/projects/projectView/insights/insights-overview/tables/project-deadline.tsx index cfcb5bc7c..a67d35742 100644 --- a/worklenz-frontend/src/pages/projects/projectView/insights/insights-overview/tables/project-deadline.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/insights/insights-overview/tables/project-deadline.tsx @@ -1,5 +1,6 @@ import { Card, Flex, Skeleton, Table, Typography } from 'antd'; import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { colors } from '@/styles/colors'; import { TableProps } from 'antd/lib'; import { simpleDateFormat } from '@/utils/simpleDateFormat'; @@ -11,6 +12,7 @@ import warningIcon from '@assets/icons/insightsIcons/warning.png'; import { useAppSelector } from '@/hooks/useAppSelector'; const ProjectDeadline = () => { + const { t } = useTranslation('project-insights'); const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer); const [loading, setLoading] = useState(false); @@ -43,12 +45,12 @@ const ProjectDeadline = () => { const columns: TableProps['columns'] = [ { key: 'name', - title: 'Name', + title: t('name'), render: (record: IInsightTasks) => {record.name}, }, { key: 'status', - title: 'Status', + title: t('status'), render: (record: IInsightTasks) => ( { }, { key: 'dueDate', - title: 'Due Date', + title: t('dueDate'), render: (record: IInsightTasks) => ( - {record.end_date ? simpleDateFormat(record.end_date) : 'N/A'} + {record.end_date ? simpleDateFormat(record.end_date) : t('noData')} ), }, @@ -89,7 +91,7 @@ const ProjectDeadline = () => { className="custom-insights-card" title={ - Project Deadline {data?.project_end_date} + {t('projectDeadline')} {data?.project_end_date} } style={{ width: '100%' }} @@ -99,15 +101,15 @@ const ProjectDeadline = () => { diff --git a/worklenz-frontend/src/pages/projects/projectView/insights/member-stats/member-stats.tsx b/worklenz-frontend/src/pages/projects/projectView/insights/member-stats/member-stats.tsx index 4e35aa14d..3fa3e1c92 100644 --- a/worklenz-frontend/src/pages/projects/projectView/insights/member-stats/member-stats.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/insights/member-stats/member-stats.tsx @@ -1,5 +1,6 @@ import ProjectStatsCard from '@/components/projects/project-stats-card'; import { Flex } from 'antd'; +import { useTranslation } from 'react-i18next'; import groupIcon from '@/assets/icons/insightsIcons/group.png'; import warningIcon from '@/assets/icons/insightsIcons/warning.png'; import unassignedIcon from '@/assets/icons/insightsIcons/block-user.png'; @@ -10,6 +11,7 @@ import logger from '@/utils/errorLogger'; import { useAppSelector } from '@/hooks/useAppSelector'; const MemberStats = () => { + const { t } = useTranslation('project-insights'); const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer); const [memberStats, setMemberStats] = useState(null); @@ -41,19 +43,19 @@ const MemberStats = () => { diff --git a/worklenz-frontend/src/pages/projects/projectView/insights/project-view-insights.tsx b/worklenz-frontend/src/pages/projects/projectView/insights/project-view-insights.tsx index d986220d0..1ce3396a2 100644 --- a/worklenz-frontend/src/pages/projects/projectView/insights/project-view-insights.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/insights/project-view-insights.tsx @@ -137,7 +137,11 @@ const ProjectViewInsights = () => { { const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer); const [creatingTask, setCreatingTask] = useState(false); + const [csvImportModalVisible, setCsvImportModalVisible] = useState(false); const handleRefresh = () => { if (!projectId) return; @@ -158,12 +160,33 @@ const ProjectViewHeader = () => { dispatch(setImportTaskTemplateDrawerOpen(true)); }; + const handleImportCSV = () => { + setCsvImportModalVisible(true); + }; + + const handleCSVImportClose = () => { + setCsvImportModalVisible(false); + }; + + const handleCSVImportComplete = () => { + // Refresh the task list after successful import + handleRefresh(); + }; + const dropdownItems = [ { key: 'import', label: (
- Import task + {t('importTask')} +
+ ), + }, + { + key: 'import-csv', + label: ( +
+ {t('importFromCSV')}
), }, @@ -213,7 +236,7 @@ const ProjectViewHeader = () => { const renderHeaderActions = () => ( - + )} @@ -301,7 +324,12 @@ const ProjectViewHeader = () => { {createPortal( { }} />, document.body, 'project-drawer')} {createPortal(, document.body, 'import-task-template')} {createPortal(, document.body, 'save-project-as-template')} - + ); }; diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index 91c1d6361..849a1394d 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -19,6 +19,7 @@ import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice'; import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; import { tabItems } from '@/lib/project/project-view-constants'; import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice'; +import { useTranslation } from 'react-i18next'; const DeleteStatusDrawer = React.lazy(() => import('@/components/project-task-filters/delete-status-drawer/delete-status-drawer')); const PhaseDrawer = React.lazy(() => import('@features/projects/singleProject/phase/PhaseDrawer')); @@ -36,6 +37,7 @@ const ProjectView = () => { const dispatch = useAppDispatch(); const [searchParams] = useSearchParams(); const { projectId } = useParams(); + const {t} = useTranslation('project-view'); const selectedProject = useAppSelector(state => state.projectReducer.project); useDocumentTitle(selectedProject?.name || 'Project View'); @@ -106,7 +108,7 @@ const ProjectView = () => { key: item.key, label: ( - {item.label} + {t(item.label)} {item.key === 'tasks-list' || item.key === 'board' ? ( + + + {(isOwnerOrAdmin) && (