diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..17de95b28 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,82 @@ +{ + "name": "worklenz", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "json2csv": "^6.0.0-alpha.2" + }, + "devDependencies": { + "@types/json2csv": "^5.0.7" + } + }, + "node_modules/@streamparser/json": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.6.tgz", + "integrity": "sha512-vL9EVn/v+OhZ+Wcs6O4iKE9EUpwHUqHmCtNUMWjqp+6dr85+XPOSGTEsqYNq1Vn04uk9SWlOVmx9J48ggJVT2Q==", + "license": "MIT" + }, + "node_modules/@types/json2csv": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/json2csv/-/json2csv-5.0.7.tgz", + "integrity": "sha512-Ma25zw9G9GEBnX8b12R4EYvnFT6dBh8L3jwsN5EUFXa+fl2dqmbLDbNWN0XuQU3rSXdsbBeCYjI9uHU2PUBxhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.0.tgz", + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/json2csv": { + "version": "6.0.0-alpha.2", + "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-6.0.0-alpha.2.tgz", + "integrity": "sha512-nJ3oP6QxN8z69IT1HmrJdfVxhU1kLTBVgMfRnNZc37YEY+jZ4nU27rBGxT4vaqM/KUCavLRhntmTuBFqZLBUcA==", + "license": "MIT", + "dependencies": { + "@streamparser/json": "^0.0.6", + "commander": "^6.2.0", + "lodash.get": "^4.4.2" + }, + "bin": { + "json2csv": "bin/json2csv.js" + }, + "engines": { + "node": ">= 12", + "npm": ">= 6.13.0" + } + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..2316d3fed --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "dependencies": { + "json2csv": "^6.0.0-alpha.2" + }, + "devDependencies": { + "@types/json2csv": "^5.0.7" + } +} diff --git a/worklenz-backend/package-lock.json b/worklenz-backend/package-lock.json index 2953defa9..e4fee5497 100644 --- a/worklenz-backend/package-lock.json +++ b/worklenz-backend/package-lock.json @@ -25,9 +25,10 @@ "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", + "exceljs": "^4.4.0", "express": "^4.18.2", "express-rate-limit": "^6.8.0", "express-session": "^1.17.3", @@ -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", @@ -8334,14 +8389,15 @@ } }, "node_modules/exceljs": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.3.0.tgz", - "integrity": "sha512-hTAeo5b5TPvf8Z02I2sKIT4kSfCnOO2bCxYX8ABqODCdAjppI3gI9VYiGCQQYVcBaBSKlFDMKlAQRqC+kV9O8w==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", "dependencies": { "archiver": "^5.0.0", "dayjs": "^1.8.34", "fast-csv": "^4.3.1", - "jszip": "^3.5.0", + "jszip": "^3.10.1", "readable-stream": "^3.6.0", "saxes": "^5.0.1", "tmp": "^0.2.0", @@ -12609,6 +12665,8 @@ "integrity": "sha512-/DDvQCiXP0KBMZ31U2mmURKaxoKt9kNqqgrSO2RuBKS+OJjw5b7uHi5jFoV8zPAUa2TNtq2XfcWL1OWDEyjwlg==", "hasInstallScript": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "bindings": "1.5.0", "nan": "2.22.0" @@ -13210,6 +13268,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 +13994,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 +14006,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 +14024,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 +14035,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 +14046,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 +14057,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 +15544,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 +16278,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..f98fe072f 100644 --- a/worklenz-backend/package.json +++ b/worklenz-backend/package.json @@ -46,9 +46,10 @@ "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", + "exceljs": "^4.4.0", "express": "^4.18.2", "express-rate-limit": "^6.8.0", "express-session": "^1.17.3", @@ -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/controllers/tasks-controller.ts b/worklenz-backend/src/controllers/tasks-controller.ts index 37ff8f844..c7bbdf308 100644 --- a/worklenz-backend/src/controllers/tasks-controller.ts +++ b/worklenz-backend/src/controllers/tasks-controller.ts @@ -1,4 +1,8 @@ import moment from "moment"; +import multer from "multer"; +import fs from "fs"; +import csv from "csv-parser"; +import ExcelJS from "exceljs"; import { IWorkLenzRequest } from "../interfaces/worklenz-request"; import { IWorkLenzResponse } from "../interfaces/worklenz-response"; @@ -22,7 +26,396 @@ import { insertToActivityLogs } from "../services/activity-logs/activity-logs.se import { IActivityLog } from "../services/activity-logs/interfaces"; import { getKey, getRootDir, uploadBase64 } from "../shared/s3"; +// Configure multer for file uploads +const upload = multer({ + dest: "temp/", + limits: { + fileSize: 5 * 1024 * 1024, // 5MB limit + }, + fileFilter: (req, file, cb) => { + if (file.mimetype === "text/csv" || file.originalname.toLowerCase().endsWith(".csv")) { + cb(null, true); + } else { + cb(null, false); + } + } +}); + +// The exportToCSV function has been moved to be a static method in the TasksController class + export default class TasksController extends TasksControllerBase { + // CSV upload middleware + public static csvUpload = upload.single("csvFile"); + + // CSV Export method + /** + * Export tasks to CSV format + * Completely rewritten for stability and reliability + * With improved error handling and content type management + */ + @HandleExceptions() + public static async exportToCSV(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + // Track if response has been sent to avoid "headers already sent" errors + const responseTracker = { sent: false }; + + try { + // Validate project ID + const { id } = req.params; + + if (!id) { + responseTracker.sent = true; + return res.status(400).send(new ServerResponse(false, null, "Project ID is required")); + } + + // First check if project exists + try { + const projectCheckQuery = "SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1::uuid) AS exists"; + const projectCheckResult = await db.query(projectCheckQuery, [id]); + + if (!projectCheckResult.rows?.[0]?.exists) { + responseTracker.sent = true; + return res.status(404).send(new ServerResponse(false, null, "Project not found")); + } + } catch (dbError) { + log_error(`Database error checking project existence: ${dbError instanceof Error ? dbError.message : String(dbError)}`); + responseTracker.sent = true; + return res.status(500).send(new ServerResponse(false, null, "Database error checking project")); + } + + // Get project name for filename + let projectName = "project"; + try { + const projectNameQuery = "SELECT name FROM projects WHERE id = $1::uuid"; + const projectResult = await db.query(projectNameQuery, [id]); + if (projectResult.rows?.[0]?.name) { + projectName = projectResult.rows[0].name; + } + } catch (dbError) { + log_error(`Non-critical error getting project name: ${dbError instanceof Error ? dbError.message : String(dbError)}`); + // Continue with default name + } + + // Format project name to be filename-safe + const safeProjectName = projectName.replace(/[^a-z0-9]/gi, "-").toLowerCase(); + + // Get project metadata with proper typing + interface ProjectMetadata { + name?: string; + description?: string; + start_date?: string; + end_date?: string; + status?: string; + task_count?: number; + } + + // Initialize with empty values + const projectMetadata: ProjectMetadata = {}; + + try { + const projectMetadataQuery = ` + SELECT + p.name, + p.description, + p.start_date, + p.end_date, + COALESCE(ps.name, 'Not Set') as status, + COUNT(t.id) as task_count + FROM projects p + LEFT JOIN project_statuses ps ON p.status_id = ps.id + LEFT JOIN tasks t ON p.id = t.project_id AND t.archived IS FALSE + WHERE p.id = $1::uuid + GROUP BY p.name, p.description, p.start_date, p.end_date, ps.name + `; + + const metadataResult = await db.query(projectMetadataQuery, [id]); + if (metadataResult.rows?.[0]) { + // Copy properties one by one for type safety + const row = metadataResult.rows[0]; + projectMetadata.name = row.name; + projectMetadata.description = row.description; + projectMetadata.start_date = row.start_date; + projectMetadata.end_date = row.end_date; + projectMetadata.status = row.status; + projectMetadata.task_count = row.task_count; + } + } catch (dbError) { + log_error(`Non-critical error getting project metadata: ${dbError instanceof Error ? dbError.message : String(dbError)}`); + // Continue with empty metadata + } + + // Get task data + let tasks = []; + try { + const tasksQuery = ` + SELECT + t.id::text AS id, + t.name AS name, + COALESCE(t.description, '') AS description, + to_char(t.start_date, 'YYYY-MM-DD') AS "startDate", + to_char(t.end_date, 'YYYY-MM-DD') AS "endDate", + COALESCE(ts.name, '') AS status, + COALESCE(tp.name, '') AS priority, + to_char(t.created_at, 'YYYY-MM-DD') AS "createdAt", + to_char(t.updated_at, 'YYYY-MM-DD') AS "updatedAt" + FROM tasks t + LEFT JOIN task_statuses ts ON t.status_id = ts.id + LEFT JOIN task_priorities tp ON t.priority_id = tp.id + WHERE t.project_id = $1::uuid + AND t.archived IS FALSE + ORDER BY t.created_at DESC + `; + + const tasksResult = await db.query(tasksQuery, [id]); + if (tasksResult.rows) { + tasks = tasksResult.rows; + } + } catch (dbError) { + log_error(`Database error getting task data: ${dbError instanceof Error ? dbError.message : String(dbError)}`); + responseTracker.sent = true; + return res.status(500).send(new ServerResponse(false, null, "Error retrieving task data")); + } + + // Generate CSV content + try { + // No header info - removed project name and task count rows + + // Define column headers + const fields = [ + "id", + "name", + "description", + "startDate", + "endDate", + "status", + "priority", + "createdAt", + "updatedAt" + ]; + + // Generate CSV content with proper escaping + const csvRows = []; + csvRows.push(fields.join(",")); + + // Process each task row + if (tasks.length > 0) { + tasks.forEach(row => { + // Properly escape values to handle commas, quotes, etc. + const escapedValues = fields.map(field => { + // Safely access field values with explicit checks to avoid issues with undefined/null + const rawValue = field in row ? row[field] : null; + const value = rawValue !== undefined && rawValue !== null ? String(rawValue) : ""; + + // If value contains comma, newline or quotes, wrap in quotes and escape inner quotes + if (value.includes(",") || value.includes("\"") || value.includes("\n")) { + return `"${value.replace(/"/g, "\"\"")}"`; + } + return value; + }); + + csvRows.push(escapedValues.join(",")); + }); + } + + // Use only the CSV rows without header info + const csvContent = csvRows.join("\n"); + + // Set response headers for file download + res.setHeader("Content-Type", "text/csv"); + res.setHeader("Content-Disposition", `attachment; filename="${safeProjectName}-tasks.csv"`); + + try { + // Send CSV content and return + return res.status(200).send(csvContent); + } catch (sendError) { + log_error(`Error sending CSV response: ${sendError instanceof Error ? sendError.message : String(sendError)}`); + if (!res.headersSent) { + return res.status(500).send(new ServerResponse(false, null, "Error sending CSV response")); + } + return res; + } + + } catch (csvError) { + log_error(`Error generating CSV: ${csvError instanceof Error ? csvError.message : String(csvError)}`); + responseTracker.sent = true; + return res.status(500).send(new ServerResponse(false, null, "Error generating CSV file")); + } + + } catch (error) { + // Final fallback error handler + log_error(`Unexpected error in exportToCSV: ${error instanceof Error ? error.message : String(error)}`); + if (!res.headersSent && !responseTracker.sent) { + responseTracker.sent = true; + return res.status(500).send(new ServerResponse(false, null, "An unexpected error occurred")); + } + return res; + } + } + + @HandleExceptions() + public static async exportToExcel(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + // Track if response has been sent to avoid "headers already sent" errors + const responseTracker = { sent: false }; + + try { + // Validate project ID + const { id } = req.params; + + if (!id) { + responseTracker.sent = true; + return res.status(400).send(new ServerResponse(false, null, "Project ID is required")); + } + + // First check if project exists + try { + const projectCheckQuery = "SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1::uuid) AS exists"; + const projectCheckResult = await db.query(projectCheckQuery, [id]); + + if (!projectCheckResult.rows?.[0]?.exists) { + responseTracker.sent = true; + return res.status(404).send(new ServerResponse(false, null, "Project not found")); + } + } catch (dbError) { + log_error(`Database error checking project existence: ${dbError instanceof Error ? dbError.message : String(dbError)}`); + responseTracker.sent = true; + return res.status(500).send(new ServerResponse(false, null, "Database error checking project")); + } + + // Get project name for filename + let projectName = "project"; + try { + const projectNameQuery = "SELECT name FROM projects WHERE id = $1::uuid"; + const projectResult = await db.query(projectNameQuery, [id]); + if (projectResult.rows?.[0]?.name) { + projectName = projectResult.rows[0].name; + } + } catch (dbError) { + log_error(`Non-critical error getting project name: ${dbError instanceof Error ? dbError.message : String(dbError)}`); + // Continue with default name + } + + // Format project name to be filename-safe + const safeProjectName = projectName.replace(/[^a-z0-9]/gi, "-").toLowerCase(); + + // Get task data + let tasks = []; + try { + const tasksQuery = ` + SELECT + t.id::text AS id, + t.name AS name, + COALESCE(t.description, '') AS description, + to_char(t.start_date, 'YYYY-MM-DD') AS "startDate", + to_char(t.end_date, 'YYYY-MM-DD') AS "endDate", + COALESCE(ts.name, '') AS status, + COALESCE(tp.name, '') AS priority, + to_char(t.created_at, 'YYYY-MM-DD') AS "createdAt", + to_char(t.updated_at, 'YYYY-MM-DD') AS "updatedAt" + FROM tasks t + LEFT JOIN task_statuses ts ON t.status_id = ts.id + LEFT JOIN task_priorities tp ON t.priority_id = tp.id + WHERE t.project_id = $1::uuid + AND t.archived IS FALSE + ORDER BY t.created_at DESC + `; + + const tasksResult = await db.query(tasksQuery, [id]); + if (tasksResult.rows) { + tasks = tasksResult.rows; + } + } catch (dbError) { + log_error(`Database error getting task data: ${dbError instanceof Error ? dbError.message : String(dbError)}`); + responseTracker.sent = true; + return res.status(500).send(new ServerResponse(false, null, "Error retrieving task data")); + } + + // Generate Excel content + try { + // Create a new Excel workbook + const workbook = new ExcelJS.Workbook(); + workbook.creator = "WorkLenz"; + workbook.lastModifiedBy = "WorkLenz"; + workbook.created = new Date(); + workbook.modified = new Date(); + + // Add a worksheet + const worksheet = workbook.addWorksheet("Tasks"); + + // Define column definitions without headers (we'll add headers manually) + const columns = [ + { key: "id", width: 36 }, + { key: "name", width: 30 }, + { key: "description", width: 40 }, + { key: "startDate", width: 12 }, + { key: "endDate", width: 12 }, + { key: "status", width: 15 }, + { key: "priority", width: 12 }, + { key: "createdAt", width: 12 }, + { key: "updatedAt", width: 12 } + ]; + + // Set column definitions without headers + worksheet.columns = columns; + + // Define headers for later use + const headers = [ + "ID", "Name", "Description", "Start Date", "End Date", "Status", "Priority", "Created At", "Updated At" + ]; + + // Insert project name in first row and merge cells across all columns + const projectNameRow = worksheet.addRow([projectName]); + projectNameRow.font = { bold: true, size: 14 }; + worksheet.mergeCells(`A1:${String.fromCharCode(64 + columns.length)}1`); + projectNameRow.alignment = { horizontal: "center" }; + + // Add a header row in the second row + const headerRow = worksheet.addRow(headers); + headerRow.font = { bold: true }; + headerRow.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFEEEEEE" } + }; + + // Add task data to the worksheet (starting from row 3) + if (tasks.length > 0) { + tasks.forEach(task => { + // Extract values in the same order as columns + const rowValues = columns.map(col => task[col.key] || ""); + worksheet.addRow(rowValues); + }); + } + + // Auto-filter for all columns (applied to the header row which is now row 2) + worksheet.autoFilter = { + from: { row: 2, column: 1 }, + to: { row: 2, column: columns.length } + }; + + // Set response headers for file download + res.setHeader("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + res.setHeader("Content-Disposition", `attachment; filename="${safeProjectName}-tasks.xlsx"`); + + // Write workbook to response + await workbook.xlsx.write(res); + return res; + + } catch (excelError) { + log_error(`Error generating Excel: ${excelError instanceof Error ? excelError.message : String(excelError)}`); + responseTracker.sent = true; + return res.status(500).send(new ServerResponse(false, null, "Error generating Excel file")); + } + + } catch (error) { + // Final fallback error handler + log_error(`Unexpected error in exportToExcel: ${error instanceof Error ? error.message : String(error)}`); + if (!res.headersSent && !responseTracker.sent) { + responseTracker.sent = true; + return res.status(500).send(new ServerResponse(false, null, "An unexpected error occurred")); + } + return res; + } + } + private static notifyProjectUpdates(socketId: string, projectId: string) { IO.getSocketById(socketId) ?.to(projectId) @@ -656,4 +1049,194 @@ export default class TasksController extends TasksControllerBase { return res.status(200).send(new ServerResponse(true, result.rows)); } + + @HandleExceptions() + public static async importFromCSV(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { id: projectId } = req.params; + const userId = req.user?.id; + const teamId = req.user?.team_id; + + console.log("=== CSV Import Debug Info ==="); + console.log("Project ID:", projectId); + console.log("User ID:", userId); + console.log("Team ID:", teamId); + console.log("File info:", { + filename: req.file?.filename, + originalname: req.file?.originalname, + size: req.file?.size, + path: req.file?.path + }); + + if (!req.file) { + return res.status(400).send(new ServerResponse(false, null, "No file uploaded")); + } + + if (!projectId || !userId || !teamId) { + return res.status(400).send(new ServerResponse(false, null, "Missing required parameters")); + } + + try { + const results: any[] = []; + const errors: string[] = []; + let processed = 0; + let skipped = 0; + + console.log("Reading CSV file from:", req.file.path); + + // Parse CSV file + await new Promise((resolve, reject) => { + fs.createReadStream(req.file!.path) + .pipe(csv()) + .on("data", (row) => { + console.log("Parsed CSV row:", row); + + // Handle different possible delimiters and BOM + const keys = Object.keys(row); + if (keys.length === 1 && keys[0].includes(";")) { + // Looks like semicolon-delimited data in a single column + const [singleColumn] = keys; + const singleValue = row[singleColumn]; + + // Split by semicolon + const parts = singleValue.split(";"); + const headerParts = singleColumn.split(";"); + + if (headerParts.length >= 2 && parts.length >= 2) { + // Clean BOM and create proper row object + const cleanRow: Record = {}; + cleanRow[headerParts[0].replace(/^\uFEFF/, "").trim()] = parts[0].trim(); + cleanRow[headerParts[1].trim()] = parts[1].trim(); + console.log("Converted row:", cleanRow); + results.push(cleanRow); + return; + } + } + + // Clean BOM from column names + const cleanRow: Record = {}; + for (const [key, value] of Object.entries(row)) { + const cleanKey = key.replace(/^\uFEFF/, "").trim(); + cleanRow[cleanKey] = value as string; + } + console.log("Cleaned row:", cleanRow); + results.push(cleanRow); + }) + .on("end", () => { + console.log("CSV parsing complete. Total rows:", results.length); + resolve(); + }) + .on("error", (error) => { + console.error("CSV parsing error:", error); + reject(error); + }); + }); + + console.log("Raw CSV results:", results); + + // Get default task status for the project + const statusQuery = ` + SELECT id FROM task_statuses + WHERE project_id = $1 AND team_id = $2 + ORDER BY sort_order LIMIT 1 + `; + console.log("Status query:", statusQuery, "with params:", [projectId, teamId]); + const statusResult = await db.query(statusQuery, [projectId, teamId]); + console.log("Status query result:", statusResult.rows); + const defaultStatusId = statusResult.rows[0]?.id; + + if (!defaultStatusId) { + throw new Error("No task statuses found for this project"); + } + + console.log("Default status ID:", defaultStatusId); + + // Process each row + for (const [index, row] of results.entries()) { + try { + console.log(`\n--- Processing row ${index + 2} ---`); + console.log("Row data:", row); + + // Map CSV column names to expected field names + const taskTitle = row["Task Title"] || row.name || ""; + const description = row["Description"] || row.description || ""; + + // Validate required fields + if (!taskTitle || taskTitle.trim() === "") { + console.log("Skipping row - no task title"); + errors.push(`Row ${index + 2}: Task title is required`); + skipped++; + continue; + } + + // Prepare task data + const taskData = { + name: taskTitle.trim(), + description: description || "", + project_id: projectId, + reporter_id: userId, + team_id: teamId, + status_id: defaultStatusId, + priority_id: null, + start: row.start || null, + end: row.end || null, + total_minutes: 0, + assignees: [] + }; + + console.log("Task data to create:", JSON.stringify(taskData, null, 2)); + + // Create task using the database function + const createQuery = `SELECT create_task($1) AS task;`; + console.log("Creating task with query:", createQuery); + console.log("Task data being sent:", JSON.stringify(taskData)); + + const result = await db.query(createQuery, [JSON.stringify(taskData)]); + console.log("Task creation result:", result.rows); + + if (result.rows && result.rows[0]?.task) { + processed++; + console.log(`Task created successfully with ID: ${result.rows[0].task.id}`); + } else { + console.log("Task creation failed - no task returned"); + errors.push(`Row ${index + 2}: Failed to create task`); + skipped++; + } + } catch (error) { + console.error(`Error processing row ${index + 2}:`, error); + errors.push(`Row ${index + 2}: ${error instanceof Error ? error.message : "Unknown error"}`); + skipped++; + } + } + + // Clean up uploaded file + if (fs.existsSync(req.file.path)) { + fs.unlinkSync(req.file.path); + console.log("Cleaned up file:", req.file.path); + } + + console.log("\n=== CSV Import Summary ==="); + console.log("Processed:", processed); + console.log("Skipped:", skipped); + console.log("Total:", results.length); + console.log("Errors:", errors.length); + + const responseData = { + processed, + skipped, + total: results.length, + errors: errors.slice(0, 10) // Limit errors to prevent response bloat + }; + + return res.status(200).send(new ServerResponse(true, responseData)); + } catch (error) { + console.error("CSV import error:", error); + + // Clean up file on error + if (req.file && fs.existsSync(req.file.path)) { + fs.unlinkSync(req.file.path); + } + + return res.status(500).send(new ServerResponse(false, null, error instanceof Error ? error.message : "CSV import failed")); + } + } } diff --git a/worklenz-backend/src/routes/apis/tasks-api-router.ts b/worklenz-backend/src/routes/apis/tasks-api-router.ts index bb6af547e..8a7eaa10a 100644 --- a/worklenz-backend/src/routes/apis/tasks-api-router.ts +++ b/worklenz-backend/src/routes/apis/tasks-api-router.ts @@ -66,6 +66,15 @@ tasksApiRouter.get("/dependency-status", safeControllerFunction(TasksControllerV tasksApiRouter.put("/labels/:id", idParamValidator, safeControllerFunction(TasksControllerV2.assignLabelsToTask)); +// CSV import route +tasksApiRouter.post("/import-csv/:id", idParamValidator, TasksController.csvUpload, safeControllerFunction(TasksController.importFromCSV)); + +// CSV export route +tasksApiRouter.get("/export-csv/:id", idParamValidator, safeControllerFunction(TasksController.exportToCSV)); + +// Excel export route +tasksApiRouter.get("/export-excel/:id", idParamValidator, safeControllerFunction(TasksController.exportToExcel)); + // Add custom column value update route tasksApiRouter.put("/:taskId/custom-column", TasksControllerV2.updateCustomColumnValue); diff --git a/worklenz-backend/src/shared/safe-controller-function.ts b/worklenz-backend/src/shared/safe-controller-function.ts index b56484deb..2603e333c 100644 --- a/worklenz-backend/src/shared/safe-controller-function.ts +++ b/worklenz-backend/src/shared/safe-controller-function.ts @@ -1,10 +1,70 @@ import {NextFunction, Request, Response} from "express"; import {IWorkLenzResponse} from "../interfaces/worklenz-response"; +import { ServerResponse } from "../models/server-response"; +import { log_error } from "../shared/utils"; -export default (fn: (_req: Request, _res: Response, next: NextFunction) => Promise) - +/** + * Wraps controller functions to provide consistent error handling + * Improved version with better promise rejection handling + */ +export default (fn: (req: Request, res: Response, next: NextFunction) => Promise) : (req: Request, res: Response, next: NextFunction) => void => { return (req: Request, res: Response, next: NextFunction): void => { - void fn(req, res, next); + // Get route information for better error logging + const routePath = req.route?.path || "unknown route"; + const method = req.method || "unknown method"; + + // Properly catch both synchronous errors and promise rejections + try { + const result = fn(req, res, next); + + // Always treat as Promise for consistent handling + Promise.resolve(result).catch((error) => { + // Log error with context + log_error(`Error in controller function [${method} ${routePath}]: ${error instanceof Error ? error.message : String(error)}`); + if (error instanceof Error && error.stack) { + log_error(error.stack); + } + + // Only send a response if one hasn't been sent yet + if (!res.headersSent) { + // Provide a more specific error message if possible + let errorMessage = "An error occurred processing your request"; + if (error instanceof Error && error.message) { + // Don't expose internal error details, just a cleaned version + const cleanMessage = error.message + .replace(/Error:/g, "") + .replace(/\n/g, " ") + .trim(); + + errorMessage = cleanMessage.length > 100 + ? `${cleanMessage.substring(0, 100)}...` + : cleanMessage; + } + + // Send appropriate status based on the error + try { + res.status(500).send(new ServerResponse(false, null, errorMessage)); + } catch (responseError) { + log_error(`Failed to send error response: ${responseError instanceof Error ? responseError.message : String(responseError)}`); + } + } + + next(error); + }); + } catch (error) { + // Handle synchronous errors + log_error(`Synchronous error in controller [${method} ${routePath}]: ${error instanceof Error ? error.message : String(error)}`); + if (error instanceof Error && error.stack) { + log_error(error.stack); + } + + // Only send a response if one hasn't been sent yet + if (!res.headersSent) { + res.status(500).send(new ServerResponse(false, null, "An unexpected error occurred")); + } + + next(error); + } }; }; 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..1a37794dc --- /dev/null +++ b/worklenz-frontend/src/components/csv-import/csv-import-modal.tsx @@ -0,0 +1,268 @@ +import React, { useState, useEffect } 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. + + // Pre-fetch CSRF token when modal opens to prevent first-time errors + useEffect(() => { + if (visible) { + const ensureCSRFToken = async () => { + const token = getCsrfToken(); + if (!token) { + console.log('Pre-fetching CSRF token...'); + await refreshCsrfToken(); + } + }; + ensureCSRFToken(); + } + }, [visible]); + + //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); + + try { + // Ensure we have a CSRF token before making the request + let csrfToken = getCsrfToken(); + if (!csrfToken) { + console.log('No CSRF token found, refreshing...'); + await refreshCsrfToken(); + csrfToken = getCsrfToken(); + 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 any).originFileObj || fileList[0]); + + 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) { + 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."; + // Also show a more user-friendly notification + notification.warning({ + message: "Security Token Required", + description: "Please refresh the page to initialize security tokens, then try uploading again.", + duration: 5, + }); + return; // Don't show the generic error notification + } else if (error.message) { + errorMessage = error.message; + } + + notification.error({ + message: t("errors.importFailed", "Import failed"), + description: 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 - Removed duplicate error display since notifications handle this */} + + {/* 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 navigate = useNavigate(); @@ -67,6 +69,225 @@ const ProjectViewHeader = () => { const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer); const [creatingTask, setCreatingTask] = useState(false); + const [csvImportModalVisible, setCsvImportModalVisible] = useState(false); + + // Common export function for different formats + const exportProject = async (format: 'csv' | 'excel') => { + if (!projectId) return; + + // Create a unique notification key for this export + const notificationKey = `export-${format}-${Date.now()}`; + + // Show initial loading notification + notification.info({ + key: notificationKey, + message: t('exportProject', 'Export'), + description: t('exportStarted', 'Preparing export...'), + duration: 0 // Keep until explicitly closed + }); + + try { + // Setup API call + const { getCsrfToken } = await import('@/api/api-client'); + const csrfToken = getCsrfToken ? getCsrfToken() ?? '' : ''; + const config = await import('@/config/env'); + const apiUrl = config.default.apiUrl; + + // Determine endpoint based on format + const endpoint = format === 'csv' + ? `${apiUrl}/api/v1/tasks/export-csv/${projectId}` + : `${apiUrl}/api/v1/tasks/export-excel/${projectId}`; + + // Create controller for timeouts + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout + + // Make the API request + let response; + try { + // Set the appropriate accept header based on the format + const acceptHeader = format === 'csv' + ? 'text/csv' + : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + + response = await fetch(endpoint, { + method: 'GET', + headers: { + 'Accept': acceptHeader, + 'X-CSRF-Token': csrfToken, + }, + credentials: 'include', + signal: controller.signal + }); + + // Clear timeout since request completed + clearTimeout(timeoutId); + + // Handle error responses + if (!response.ok) { + let errorMessage = `Status: ${response.status}`; + let errorDetails = ''; + + try { + // Try to parse error details from response + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + const errorJson = await response.json(); + errorDetails = errorJson.message || JSON.stringify(errorJson); + } else { + const errorText = await response.text(); + if (errorText && errorText.length < 100) { // Only use text if it's reasonably short + errorDetails = errorText; + } + } + } catch (parseError) { + console.error('Error parsing error response:', parseError); + } + + // Show appropriate error based on status code + let errorDescription = t('exportFailed', 'Export failed. Please try again.'); + if (response.status === 404) { + errorDescription = t('projectNotFound', 'Project not found or you don\'t have access.'); + } else if (response.status === 400) { + errorDescription = t('invalidRequest', 'Invalid request. Please try again.'); + } else if (response.status >= 500) { + errorDescription = t('serverError', 'Server error. Please try again later.'); + } + + if (errorDetails) { + errorDescription += ` (${errorDetails})`; + } + + notification.error({ + key: notificationKey, + message: t('exportProject', 'Export'), + description: errorDescription, + }); + + throw new Error(`Failed to export CSV. ${errorMessage} - ${errorDetails}`); + } + } catch (fetchError) { + // Handle network errors or aborted requests + if (typeof fetchError === 'object' && fetchError !== null && 'name' in fetchError && (fetchError as { name?: string }).name === 'AbortError') { + notification.error({ + key: notificationKey, + message: t('exportProject', 'Export'), + description: t('exportTimeout', 'Export timed out. The server might be busy or the dataset is too large.'), + }); + } else { + notification.error({ + key: notificationKey, + message: t('exportProject', 'Export'), + description: t('networkError', 'Network error. Please check your connection and try again.'), + }); + } + throw fetchError; + } + + // Process successful response + try { + // First check content type to ensure we got CSV (accepting various valid MIME types) + const contentType = response.headers.get('content-type'); + + // Define valid content types for both CSV and Excel + const validCsvTypes = ['text/csv', 'application/csv', 'text/plain', 'text/x-csv', 'application/x-csv']; + const validExcelTypes = ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel']; + + // Select appropriate valid types based on the format + const validTypes = format === 'csv' ? validCsvTypes : validExcelTypes; + + const isValidContentType = contentType && + validTypes.some(type => contentType.toLowerCase().includes(type)); + + if (!isValidContentType) { + console.error(`Unexpected content type: ${contentType}`); + notification.error({ + key: notificationKey, + message: t('exportProject', 'Export'), + description: t('invalidResponseFormat', 'Received invalid data format from server.'), + }); + throw new Error('Invalid content type received'); + } + + // Get blob data with timeout and error handling + let blob; + const blobTimeoutId = setTimeout(() => { + notification.error({ + key: notificationKey, + message: t('exportProject', 'Export'), + description: t('processingTimeout', 'Processing timed out. The file may be too large.'), + }); + }, 10000); // 10 second timeout for blob processing + + try { + blob = await response.blob(); + clearTimeout(blobTimeoutId); + } catch (blobError) { + clearTimeout(blobTimeoutId); + console.error('Error getting blob:', blobError); + notification.error({ + key: notificationKey, + message: t('exportProject', 'Export'), + description: t('blobError', 'Error processing response data. Please try again.'), + }); + throw new Error('Failed to process response data'); + } + + // Check blob size + if (!blob || blob.size === 0) { + notification.warning({ + key: notificationKey, + message: t('exportProject', 'Export'), + description: t('noData', 'No task data available for export.'), + }); + throw new Error('Empty blob received'); + } + + // Create filename + const safeProjectName = selectedProject?.name + ? selectedProject.name.replace(/[^a-z0-9]/gi, '-').toLowerCase() + : `project-${projectId}`; + + // Use appropriate file extension based on format + const extension = format === 'csv' ? 'csv' : 'xlsx'; + const filename = `${safeProjectName}-tasks-${new Date().toISOString().split('T')[0]}.${extension}`; + + // Create and trigger download + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + // Clean up + setTimeout(() => window.URL.revokeObjectURL(url), 100); + + // Show success notification + notification.success({ + key: notificationKey, + message: t('exportProject', 'Export'), + description: t('exportCompleted', 'Export completed successfully!'), + }); + } catch (blobError) { + console.error('Error processing blob:', blobError); + + // Only show error notification if it's not a timeout (which already has its own notification) + if (!(typeof blobError === 'object' && blobError !== null && 'name' in blobError && (blobError as { name?: string }).name === 'AbortError')) { + notification.error({ + key: notificationKey, + message: t('exportProject', 'Export'), + description: t('processingError', 'Error processing the export data. Please try again.'), + }); + } + throw blobError; + } + } catch (error) { + console.error(`${format.toUpperCase()} export error:`, error); + // Don't show duplicate error messages - we've already shown specific ones above + } + }; const handleRefresh = () => { if (!projectId) return; @@ -158,12 +379,29 @@ const ProjectViewHeader = () => { dispatch(setImportTaskTemplateDrawerOpen(true)); }; + const handleCSVImport = () => { + setCsvImportModalVisible(true); + }; + + const handleCSVImportComplete = () => { + setCsvImportModalVisible(false); + handleRefresh(); + }; + const dropdownItems = [ { key: 'import', label: (
- Import task + {t('importTask')} +
+ ), + }, + { + key: 'csv-import', + label: ( +
+ Import from CSV
), }, @@ -213,7 +451,7 @@ const ProjectViewHeader = () => { const renderHeaderActions = () => ( - + + + + {(isOwnerOrAdmin) && ( - + )} @@ -301,7 +564,12 @@ const ProjectViewHeader = () => { {createPortal( { }} />, document.body, 'project-drawer')} {createPortal(, document.body, 'import-task-template')} {createPortal(, document.body, 'save-project-as-template')} - + setCsvImportModalVisible(false)} + onImportComplete={handleCSVImportComplete} + /> ); }; 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' ? (