feat: add i18n internationalization support
- Add react-i18next, i18next with browser language detection - Support Traditional Chinese (zh-TW) and English (en) - Default language: zh-TW, stored in localStorage - Create 10 translation namespaces (common, auth, dashboard, tasks, etc.) - Add LanguageSwitcher component in header - Translate pages: Login, Dashboard, Tasks, Spaces, Workload, Audit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
163
frontend/package-lock.json
generated
163
frontend/package-lock.json
generated
@@ -15,8 +15,12 @@
|
|||||||
"@fullcalendar/timegrid": "^6.1.20",
|
"@fullcalendar/timegrid": "^6.1.20",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"frappe-gantt": "^1.0.4",
|
"frappe-gantt": "^1.0.4",
|
||||||
|
"i18next": "^25.7.4",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
|
"i18next-http-backend": "^3.0.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-i18next": "^16.5.1",
|
||||||
"react-router-dom": "^6.21.0"
|
"react-router-dom": "^6.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -340,7 +344,6 @@
|
|||||||
"version": "7.28.4",
|
"version": "7.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -1913,6 +1916,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-fetch": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-fetch": "^2.6.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css-tree": {
|
"node_modules/css-tree": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
|
||||||
@@ -2382,6 +2394,15 @@
|
|||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-parse-stringify": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"void-elements": "3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-proxy-agent": {
|
"node_modules/http-proxy-agent": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||||
@@ -2410,6 +2431,56 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/i18next": {
|
||||||
|
"version": "25.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.4.tgz",
|
||||||
|
"integrity": "sha512-hRkpEblXXcXSNbw8mBNq9042OEetgyB/ahc/X17uV/khPwzV+uB8RHceHh3qavyrkPJvmXFKXME2Sy1E0KjAfw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://locize.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://locize.com/i18next.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/i18next-browser-languagedetector": {
|
||||||
|
"version": "8.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
|
||||||
|
"integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.23.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/i18next-http-backend": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-fetch": "4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/indent-string": {
|
"node_modules/indent-string": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||||
@@ -2615,6 +2686,48 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch/node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch/node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch/node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
@@ -2771,6 +2884,33 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-i18next": {
|
||||||
|
"version": "16.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.1.tgz",
|
||||||
|
"integrity": "sha512-Hks6UIRZWW4c+qDAnx1csVsCGYeIR4MoBGQgJ+NUoNnO6qLxXuf8zu0xdcinyXUORgGzCdRsexxO1Xzv3sTdnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.4",
|
||||||
|
"html-parse-stringify": "^3.0.1",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"i18next": ">= 25.6.2",
|
||||||
|
"react": ">= 16.8.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
@@ -3063,8 +3203,9 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -3104,6 +3245,15 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
@@ -3779,6 +3929,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/void-elements": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/w3c-xmlserializer": {
|
"node_modules/w3c-xmlserializer": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
|||||||
@@ -19,8 +19,12 @@
|
|||||||
"@fullcalendar/timegrid": "^6.1.20",
|
"@fullcalendar/timegrid": "^6.1.20",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"frappe-gantt": "^1.0.4",
|
"frappe-gantt": "^1.0.4",
|
||||||
|
"i18next": "^25.7.4",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
|
"i18next-http-backend": "^3.0.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-i18next": "^16.5.1",
|
||||||
"react-router-dom": "^6.21.0"
|
"react-router-dom": "^6.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
56
frontend/public/locales/en/audit.json
Normal file
56
frontend/public/locales/en/audit.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"title": "Audit Log",
|
||||||
|
"subtitle": "Track all operations in the system",
|
||||||
|
"filters": {
|
||||||
|
"action": "Action Type",
|
||||||
|
"user": "User",
|
||||||
|
"entity": "Entity Type",
|
||||||
|
"dateRange": "Date Range",
|
||||||
|
"allActions": "All Actions",
|
||||||
|
"allUsers": "All Users",
|
||||||
|
"allEntities": "All Entities"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"create": "Create",
|
||||||
|
"update": "Update",
|
||||||
|
"delete": "Delete",
|
||||||
|
"login": "Login",
|
||||||
|
"logout": "Logout",
|
||||||
|
"assign": "Assign",
|
||||||
|
"unassign": "Unassign",
|
||||||
|
"statusChange": "Status Change",
|
||||||
|
"comment": "Comment",
|
||||||
|
"upload": "Upload",
|
||||||
|
"download": "Download"
|
||||||
|
},
|
||||||
|
"entities": {
|
||||||
|
"task": "Task",
|
||||||
|
"project": "Project",
|
||||||
|
"space": "Space",
|
||||||
|
"user": "User",
|
||||||
|
"comment": "Comment",
|
||||||
|
"attachment": "Attachment"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"timestamp": "Timestamp",
|
||||||
|
"user": "User",
|
||||||
|
"action": "Action",
|
||||||
|
"entity": "Entity",
|
||||||
|
"details": "Details",
|
||||||
|
"ipAddress": "IP Address"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"before": "Before",
|
||||||
|
"after": "After",
|
||||||
|
"changes": "Changes"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"title": "Export Log",
|
||||||
|
"csv": "Export as CSV",
|
||||||
|
"json": "Export as JSON"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "No Audit Records",
|
||||||
|
"description": "No audit records match the current filters"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
frontend/public/locales/en/auth.json
Normal file
28
frontend/public/locales/en/auth.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"title": "Login",
|
||||||
|
"subtitle": "Sign in to your account",
|
||||||
|
"email": "Email",
|
||||||
|
"emailPlaceholder": "Enter your email",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordPlaceholder": "Enter your password",
|
||||||
|
"rememberMe": "Remember me",
|
||||||
|
"forgotPassword": "Forgot password?",
|
||||||
|
"submit": "Sign in",
|
||||||
|
"loggingIn": "Signing in...",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"signUp": "Sign up"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"invalidCredentials": "Invalid email or password",
|
||||||
|
"accountLocked": "Account is locked. Please contact administrator.",
|
||||||
|
"emailRequired": "Email is required",
|
||||||
|
"passwordRequired": "Password is required",
|
||||||
|
"invalidEmail": "Please enter a valid email address",
|
||||||
|
"loginFailed": "Login failed. Please try again later."
|
||||||
|
},
|
||||||
|
"welcome": {
|
||||||
|
"title": "Project Control Center",
|
||||||
|
"subtitle": "Manage your projects, tasks, and teams"
|
||||||
|
}
|
||||||
|
}
|
||||||
102
frontend/public/locales/en/common.json
Normal file
102
frontend/public/locales/en/common.json
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"buttons": {
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"create": "Create",
|
||||||
|
"close": "Close",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous",
|
||||||
|
"submit": "Submit",
|
||||||
|
"reset": "Reset",
|
||||||
|
"search": "Search",
|
||||||
|
"filter": "Filter",
|
||||||
|
"export": "Export",
|
||||||
|
"import": "Import",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove",
|
||||||
|
"view": "View",
|
||||||
|
"download": "Download",
|
||||||
|
"upload": "Upload"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"noData": "No data",
|
||||||
|
"required": "Required",
|
||||||
|
"optional": "Optional",
|
||||||
|
"all": "All",
|
||||||
|
"none": "None",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"actions": "Actions",
|
||||||
|
"details": "Details",
|
||||||
|
"description": "Description",
|
||||||
|
"name": "Name",
|
||||||
|
"title": "Title",
|
||||||
|
"status": "Status",
|
||||||
|
"type": "Type",
|
||||||
|
"date": "Date",
|
||||||
|
"time": "Time",
|
||||||
|
"createdAt": "Created at",
|
||||||
|
"updatedAt": "Updated at",
|
||||||
|
"createdBy": "Created by",
|
||||||
|
"assignee": "Assignee",
|
||||||
|
"selectAssignee": "Select assignee...",
|
||||||
|
"searchUsers": "Search users...",
|
||||||
|
"noUsersFound": "No users found",
|
||||||
|
"typeToSearch": "Type to search users"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"success": "Operation successful",
|
||||||
|
"error": "Operation failed",
|
||||||
|
"confirmDelete": "Are you sure you want to delete? This action cannot be undone.",
|
||||||
|
"unsavedChanges": "You have unsaved changes. Are you sure you want to leave?",
|
||||||
|
"networkError": "Network error. Please try again later.",
|
||||||
|
"sessionExpired": "Session expired. Please log in again.",
|
||||||
|
"permissionDenied": "You do not have permission to perform this action.",
|
||||||
|
"notFound": "The requested resource was not found."
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "This field is required",
|
||||||
|
"email": "Please enter a valid email address",
|
||||||
|
"minLength": "Minimum {{min}} characters required",
|
||||||
|
"maxLength": "Maximum {{max}} characters allowed",
|
||||||
|
"invalidFormat": "Invalid format"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"spaces": "Spaces",
|
||||||
|
"projects": "Projects",
|
||||||
|
"tasks": "Tasks",
|
||||||
|
"workload": "Workload",
|
||||||
|
"health": "Project Health",
|
||||||
|
"audit": "Audit Log",
|
||||||
|
"settings": "Settings",
|
||||||
|
"logout": "Logout"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"switch": "Switch language",
|
||||||
|
"zhTW": "繁體中文",
|
||||||
|
"en": "English"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Notifications",
|
||||||
|
"markAllRead": "Mark all as read",
|
||||||
|
"noNotifications": "No notifications",
|
||||||
|
"viewAll": "View all"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"page": "Page {{page}}",
|
||||||
|
"of": "of {{total}}",
|
||||||
|
"showing": "Showing {{from}}-{{to}} of {{total}}",
|
||||||
|
"itemsPerPage": "Items per page"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
frontend/public/locales/en/dashboard.json
Normal file
47
frontend/public/locales/en/dashboard.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"title": "Dashboard",
|
||||||
|
"welcome": "Welcome back, {{name}}",
|
||||||
|
"stats": {
|
||||||
|
"myTasks": "My Tasks",
|
||||||
|
"overdueTasks": "Overdue Tasks",
|
||||||
|
"completedTasks": "Completed Tasks",
|
||||||
|
"activeProjects": "Active Projects",
|
||||||
|
"teamMembers": "Team Members",
|
||||||
|
"totalTasks": "Total Tasks",
|
||||||
|
"pendingReview": "Pending Review"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"recentActivity": "Recent Activity",
|
||||||
|
"upcomingDeadlines": "Upcoming Deadlines",
|
||||||
|
"myAssignedTasks": "Tasks Assigned to Me",
|
||||||
|
"projectOverview": "Project Overview",
|
||||||
|
"quickActions": "Quick Actions"
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"taskCreated": "Created task \"{{task}}\"",
|
||||||
|
"taskCompleted": "Completed task \"{{task}}\"",
|
||||||
|
"taskAssigned": "Assigned task \"{{task}}\" to {{assignee}}",
|
||||||
|
"commentAdded": "Added a comment on task \"{{task}}\"",
|
||||||
|
"projectCreated": "Created project \"{{project}}\"",
|
||||||
|
"noRecentActivity": "No recent activity"
|
||||||
|
},
|
||||||
|
"deadlines": {
|
||||||
|
"today": "Due today",
|
||||||
|
"tomorrow": "Due tomorrow",
|
||||||
|
"thisWeek": "Due this week",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"noUpcoming": "No upcoming deadlines"
|
||||||
|
},
|
||||||
|
"quickActions": {
|
||||||
|
"createTask": "Create Task",
|
||||||
|
"createProject": "Create Project",
|
||||||
|
"viewAllTasks": "View All Tasks",
|
||||||
|
"viewWorkload": "View Workload"
|
||||||
|
},
|
||||||
|
"widgets": {
|
||||||
|
"tasksByStatus": "Tasks by Status",
|
||||||
|
"tasksByPriority": "Tasks by Priority",
|
||||||
|
"projectProgress": "Project Progress",
|
||||||
|
"teamWorkload": "Team Workload"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
frontend/public/locales/en/health.json
Normal file
48
frontend/public/locales/en/health.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"title": "Project Health",
|
||||||
|
"subtitle": "Monitor the overall health of projects",
|
||||||
|
"overall": {
|
||||||
|
"title": "Overall Health",
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"atRisk": "At Risk",
|
||||||
|
"critical": "Critical"
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"budget": "Budget",
|
||||||
|
"scope": "Scope",
|
||||||
|
"quality": "Quality",
|
||||||
|
"resources": "Resources"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"onTrack": "On Track",
|
||||||
|
"delayed": "Delayed",
|
||||||
|
"ahead": "Ahead",
|
||||||
|
"overBudget": "Over Budget",
|
||||||
|
"underBudget": "Under Budget"
|
||||||
|
},
|
||||||
|
"indicators": {
|
||||||
|
"title": "Health Indicators",
|
||||||
|
"taskCompletion": "Task Completion Rate",
|
||||||
|
"onTimeDelivery": "On-time Delivery Rate",
|
||||||
|
"blockedTasks": "Blocked Tasks",
|
||||||
|
"overdueRate": "Overdue Rate",
|
||||||
|
"velocityTrend": "Velocity Trend"
|
||||||
|
},
|
||||||
|
"risks": {
|
||||||
|
"title": "Risks",
|
||||||
|
"high": "High Risk",
|
||||||
|
"medium": "Medium Risk",
|
||||||
|
"low": "Low Risk",
|
||||||
|
"mitigated": "Mitigated"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"viewDetails": "View Details",
|
||||||
|
"exportReport": "Export Report",
|
||||||
|
"setAlert": "Set Alert"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "No Health Data",
|
||||||
|
"description": "The project needs more data to display health metrics"
|
||||||
|
}
|
||||||
|
}
|
||||||
50
frontend/public/locales/en/projects.json
Normal file
50
frontend/public/locales/en/projects.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"title": "Projects",
|
||||||
|
"createProject": "Create Project",
|
||||||
|
"editProject": "Edit Project",
|
||||||
|
"deleteProject": "Delete Project",
|
||||||
|
"projectSettings": "Project Settings",
|
||||||
|
"fields": {
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Enter project name",
|
||||||
|
"description": "Description",
|
||||||
|
"descriptionPlaceholder": "Enter project description",
|
||||||
|
"status": "Status",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"owner": "Project Owner",
|
||||||
|
"space": "Space"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"planning": "Planning",
|
||||||
|
"active": "Active",
|
||||||
|
"on_hold": "On Hold",
|
||||||
|
"completed": "Completed",
|
||||||
|
"cancelled": "Cancelled"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"tasks": "Tasks",
|
||||||
|
"members": "Members",
|
||||||
|
"settings": "Settings",
|
||||||
|
"files": "Files",
|
||||||
|
"activity": "Activity"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"totalTasks": "Total Tasks",
|
||||||
|
"completedTasks": "Completed",
|
||||||
|
"inProgress": "In Progress",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"progress": "Overall Progress"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"created": "Project created",
|
||||||
|
"updated": "Project updated",
|
||||||
|
"deleted": "Project deleted",
|
||||||
|
"confirmDelete": "Are you sure you want to delete this project? This will delete all related tasks."
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "No Projects",
|
||||||
|
"description": "Create your first project to start managing tasks"
|
||||||
|
}
|
||||||
|
}
|
||||||
73
frontend/public/locales/en/settings.json
Normal file
73
frontend/public/locales/en/settings.json
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"title": "Settings",
|
||||||
|
"projectSettings": "Project Settings",
|
||||||
|
"tabs": {
|
||||||
|
"general": "General",
|
||||||
|
"members": "Members",
|
||||||
|
"customFields": "Custom Fields",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"integrations": "Integrations",
|
||||||
|
"danger": "Danger Zone"
|
||||||
|
},
|
||||||
|
"general": {
|
||||||
|
"title": "General Settings",
|
||||||
|
"projectName": "Project Name",
|
||||||
|
"description": "Description",
|
||||||
|
"status": "Status",
|
||||||
|
"visibility": "Visibility",
|
||||||
|
"public": "Public",
|
||||||
|
"private": "Private"
|
||||||
|
},
|
||||||
|
"members": {
|
||||||
|
"title": "Member Management",
|
||||||
|
"invite": "Invite Member",
|
||||||
|
"inviteByEmail": "Invite by email",
|
||||||
|
"emailPlaceholder": "Enter email address",
|
||||||
|
"role": "Role",
|
||||||
|
"changeRole": "Change Role",
|
||||||
|
"remove": "Remove Member",
|
||||||
|
"confirmRemove": "Are you sure you want to remove this member?"
|
||||||
|
},
|
||||||
|
"customFields": {
|
||||||
|
"title": "Custom Fields",
|
||||||
|
"add": "Add Field",
|
||||||
|
"edit": "Edit Field",
|
||||||
|
"delete": "Delete Field",
|
||||||
|
"fieldName": "Field Name",
|
||||||
|
"fieldType": "Field Type",
|
||||||
|
"required": "Required",
|
||||||
|
"types": {
|
||||||
|
"text": "Text",
|
||||||
|
"number": "Number",
|
||||||
|
"date": "Date",
|
||||||
|
"select": "Dropdown",
|
||||||
|
"multiSelect": "Multi-select",
|
||||||
|
"checkbox": "Checkbox"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Notification Settings",
|
||||||
|
"email": "Email Notifications",
|
||||||
|
"inApp": "In-app Notifications",
|
||||||
|
"taskAssigned": "When a task is assigned to me",
|
||||||
|
"taskCompleted": "When a task is completed",
|
||||||
|
"commentAdded": "When a comment is added",
|
||||||
|
"dueDateApproaching": "When a due date is approaching"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"title": "Danger Zone",
|
||||||
|
"archive": "Archive Project",
|
||||||
|
"archiveDescription": "Archive this project. The project will become read-only.",
|
||||||
|
"delete": "Delete Project",
|
||||||
|
"deleteDescription": "Permanently delete this project and all its data. This action cannot be undone.",
|
||||||
|
"confirmArchive": "Are you sure you want to archive this project?",
|
||||||
|
"confirmDelete": "Are you sure you want to delete this project? Type the project name to confirm:",
|
||||||
|
"typeToConfirm": "Type \"{{name}}\" to confirm"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"saved": "Settings saved",
|
||||||
|
"memberInvited": "Invitation sent",
|
||||||
|
"memberRemoved": "Member removed",
|
||||||
|
"roleChanged": "Role changed"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
frontend/public/locales/en/spaces.json
Normal file
39
frontend/public/locales/en/spaces.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"title": "Spaces",
|
||||||
|
"createSpace": "Create Space",
|
||||||
|
"editSpace": "Edit Space",
|
||||||
|
"deleteSpace": "Delete Space",
|
||||||
|
"fields": {
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Enter space name",
|
||||||
|
"description": "Description",
|
||||||
|
"descriptionPlaceholder": "Enter space description",
|
||||||
|
"icon": "Icon",
|
||||||
|
"color": "Color"
|
||||||
|
},
|
||||||
|
"members": {
|
||||||
|
"title": "Members",
|
||||||
|
"add": "Add Member",
|
||||||
|
"remove": "Remove Member",
|
||||||
|
"role": "Role",
|
||||||
|
"owner": "Owner",
|
||||||
|
"admin": "Admin",
|
||||||
|
"member": "Member",
|
||||||
|
"viewer": "Viewer"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"projects": "Projects",
|
||||||
|
"members": "Members",
|
||||||
|
"tasks": "Tasks"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"created": "Space created",
|
||||||
|
"updated": "Space updated",
|
||||||
|
"deleted": "Space deleted",
|
||||||
|
"confirmDelete": "Are you sure you want to delete this space? This will delete all related projects and tasks."
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "No Spaces",
|
||||||
|
"description": "Create your first space to organize projects"
|
||||||
|
}
|
||||||
|
}
|
||||||
134
frontend/public/locales/en/tasks.json
Normal file
134
frontend/public/locales/en/tasks.json
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
{
|
||||||
|
"title": "Tasks",
|
||||||
|
"createTask": "Create Task",
|
||||||
|
"editTask": "Edit Task",
|
||||||
|
"deleteTask": "Delete Task",
|
||||||
|
"taskDetails": "Task Details",
|
||||||
|
"fields": {
|
||||||
|
"title": "Title",
|
||||||
|
"titlePlaceholder": "Enter task title",
|
||||||
|
"description": "Description",
|
||||||
|
"descriptionPlaceholder": "Enter task description",
|
||||||
|
"status": "Status",
|
||||||
|
"priority": "Priority",
|
||||||
|
"assignee": "Assignee",
|
||||||
|
"dueDate": "Due Date",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"estimatedHours": "Estimated Hours",
|
||||||
|
"actualHours": "Actual Hours",
|
||||||
|
"progress": "Progress",
|
||||||
|
"tags": "Tags",
|
||||||
|
"parent": "Parent Task",
|
||||||
|
"subtasks": "Subtasks",
|
||||||
|
"attachments": "Attachments",
|
||||||
|
"comments": "Comments",
|
||||||
|
"watchers": "Watchers",
|
||||||
|
"blockers": "Blockers"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"todo": "To Do",
|
||||||
|
"in_progress": "In Progress",
|
||||||
|
"review": "In Review",
|
||||||
|
"done": "Done",
|
||||||
|
"cancelled": "Cancelled",
|
||||||
|
"blocked": "Blocked"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"low": "Low",
|
||||||
|
"medium": "Medium",
|
||||||
|
"high": "High",
|
||||||
|
"urgent": "Urgent"
|
||||||
|
},
|
||||||
|
"views": {
|
||||||
|
"list": "List",
|
||||||
|
"kanban": "Kanban",
|
||||||
|
"calendar": "Calendar",
|
||||||
|
"gantt": "Gantt",
|
||||||
|
"timeline": "Timeline"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"all": "All Tasks",
|
||||||
|
"myTasks": "My Tasks",
|
||||||
|
"unassigned": "Unassigned",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"dueThisWeek": "Due This Week",
|
||||||
|
"highPriority": "High Priority",
|
||||||
|
"recentlyUpdated": "Recently Updated"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"sortBy": "Sort by",
|
||||||
|
"dueDate": "Due Date",
|
||||||
|
"priority": "Priority",
|
||||||
|
"status": "Status",
|
||||||
|
"title": "Title",
|
||||||
|
"createdAt": "Created At",
|
||||||
|
"updatedAt": "Updated At",
|
||||||
|
"ascending": "Ascending",
|
||||||
|
"descending": "Descending"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"assign": "Assign",
|
||||||
|
"reassign": "Reassign",
|
||||||
|
"changeStatus": "Change Status",
|
||||||
|
"changePriority": "Change Priority",
|
||||||
|
"addSubtask": "Add Subtask",
|
||||||
|
"addComment": "Add Comment",
|
||||||
|
"addAttachment": "Add Attachment",
|
||||||
|
"addWatcher": "Add Watcher",
|
||||||
|
"removeWatcher": "Remove Watcher",
|
||||||
|
"duplicate": "Duplicate Task",
|
||||||
|
"archive": "Archive",
|
||||||
|
"restore": "Restore",
|
||||||
|
"moveToProject": "Move to Project"
|
||||||
|
},
|
||||||
|
"subtasks": {
|
||||||
|
"title": "Subtasks",
|
||||||
|
"add": "Add Subtask",
|
||||||
|
"placeholder": "Enter subtask title",
|
||||||
|
"completed": "{{count}} / {{total}} completed",
|
||||||
|
"empty": "No subtasks"
|
||||||
|
},
|
||||||
|
"comments": {
|
||||||
|
"title": "Comments",
|
||||||
|
"add": "Add Comment",
|
||||||
|
"placeholder": "Write your comment...",
|
||||||
|
"edited": "edited",
|
||||||
|
"delete": "Delete Comment",
|
||||||
|
"confirmDelete": "Are you sure you want to delete this comment?",
|
||||||
|
"empty": "No comments yet",
|
||||||
|
"reply": "Reply"
|
||||||
|
},
|
||||||
|
"attachments": {
|
||||||
|
"title": "Attachments",
|
||||||
|
"add": "Add Attachment",
|
||||||
|
"upload": "Upload File",
|
||||||
|
"dragDrop": "Drag and drop files here or click to upload",
|
||||||
|
"maxSize": "Max file size: {{size}}MB",
|
||||||
|
"downloading": "Downloading...",
|
||||||
|
"empty": "No attachments"
|
||||||
|
},
|
||||||
|
"blockers": {
|
||||||
|
"title": "Blockers",
|
||||||
|
"add": "Add Blocker",
|
||||||
|
"blockedBy": "Blocked by",
|
||||||
|
"blocking": "Blocking",
|
||||||
|
"remove": "Remove blocker",
|
||||||
|
"empty": "No blockers"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"created": "Task created",
|
||||||
|
"updated": "Task updated",
|
||||||
|
"deleted": "Task deleted",
|
||||||
|
"statusChanged": "Status changed to \"{{status}}\"",
|
||||||
|
"assigned": "Assigned to {{assignee}}",
|
||||||
|
"unassigned": "Unassigned",
|
||||||
|
"commentAdded": "Comment added",
|
||||||
|
"attachmentUploaded": "Attachment uploaded",
|
||||||
|
"confirmDelete": "Are you sure you want to delete this task? This action cannot be undone."
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "No Tasks",
|
||||||
|
"description": "There are no tasks yet. Create your first task to get started!",
|
||||||
|
"filtered": "No tasks match your filters"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
frontend/public/locales/en/workload.json
Normal file
42
frontend/public/locales/en/workload.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"title": "Workload",
|
||||||
|
"subtitle": "View team member workload distribution",
|
||||||
|
"filters": {
|
||||||
|
"project": "Project",
|
||||||
|
"allProjects": "All Projects",
|
||||||
|
"dateRange": "Date Range",
|
||||||
|
"thisWeek": "This Week",
|
||||||
|
"thisMonth": "This Month",
|
||||||
|
"custom": "Custom"
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"totalHours": "Total Hours",
|
||||||
|
"assignedTasks": "Assigned Tasks",
|
||||||
|
"completedTasks": "Completed Tasks",
|
||||||
|
"overdueTasks": "Overdue Tasks",
|
||||||
|
"utilization": "Utilization"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"hoursPerDay": "Hours per Day",
|
||||||
|
"taskDistribution": "Task Distribution",
|
||||||
|
"byProject": "By Project",
|
||||||
|
"byPriority": "By Priority"
|
||||||
|
},
|
||||||
|
"team": {
|
||||||
|
"title": "Team Members",
|
||||||
|
"member": "Member",
|
||||||
|
"allocated": "Allocated",
|
||||||
|
"available": "Available",
|
||||||
|
"overloaded": "Overloaded",
|
||||||
|
"underutilized": "Underutilized"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"balanced": "Balanced",
|
||||||
|
"overloaded": "Overloaded",
|
||||||
|
"underutilized": "Underutilized"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "No Workload Data",
|
||||||
|
"description": "Not enough data to display workload"
|
||||||
|
}
|
||||||
|
}
|
||||||
56
frontend/public/locales/zh-TW/audit.json
Normal file
56
frontend/public/locales/zh-TW/audit.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"title": "稽核日誌",
|
||||||
|
"subtitle": "追蹤系統中的所有操作記錄",
|
||||||
|
"filters": {
|
||||||
|
"action": "操作類型",
|
||||||
|
"user": "使用者",
|
||||||
|
"entity": "實體類型",
|
||||||
|
"dateRange": "日期範圍",
|
||||||
|
"allActions": "所有操作",
|
||||||
|
"allUsers": "所有使用者",
|
||||||
|
"allEntities": "所有實體"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"create": "建立",
|
||||||
|
"update": "更新",
|
||||||
|
"delete": "刪除",
|
||||||
|
"login": "登入",
|
||||||
|
"logout": "登出",
|
||||||
|
"assign": "指派",
|
||||||
|
"unassign": "取消指派",
|
||||||
|
"statusChange": "狀態變更",
|
||||||
|
"comment": "留言",
|
||||||
|
"upload": "上傳",
|
||||||
|
"download": "下載"
|
||||||
|
},
|
||||||
|
"entities": {
|
||||||
|
"task": "任務",
|
||||||
|
"project": "專案",
|
||||||
|
"space": "工作空間",
|
||||||
|
"user": "使用者",
|
||||||
|
"comment": "留言",
|
||||||
|
"attachment": "附件"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"timestamp": "時間",
|
||||||
|
"user": "使用者",
|
||||||
|
"action": "操作",
|
||||||
|
"entity": "實體",
|
||||||
|
"details": "詳情",
|
||||||
|
"ipAddress": "IP 位址"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"before": "變更前",
|
||||||
|
"after": "變更後",
|
||||||
|
"changes": "變更內容"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"title": "匯出日誌",
|
||||||
|
"csv": "匯出為 CSV",
|
||||||
|
"json": "匯出為 JSON"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "沒有稽核記錄",
|
||||||
|
"description": "目前沒有符合條件的稽核記錄"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
frontend/public/locales/zh-TW/auth.json
Normal file
28
frontend/public/locales/zh-TW/auth.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"title": "登入",
|
||||||
|
"subtitle": "登入您的帳戶",
|
||||||
|
"email": "電子郵件",
|
||||||
|
"emailPlaceholder": "請輸入電子郵件",
|
||||||
|
"password": "密碼",
|
||||||
|
"passwordPlaceholder": "請輸入密碼",
|
||||||
|
"rememberMe": "記住我",
|
||||||
|
"forgotPassword": "忘記密碼?",
|
||||||
|
"submit": "登入",
|
||||||
|
"loggingIn": "登入中...",
|
||||||
|
"noAccount": "還沒有帳戶?",
|
||||||
|
"signUp": "註冊"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"invalidCredentials": "電子郵件或密碼錯誤",
|
||||||
|
"accountLocked": "帳戶已被鎖定,請聯繫管理員",
|
||||||
|
"emailRequired": "請輸入電子郵件",
|
||||||
|
"passwordRequired": "請輸入密碼",
|
||||||
|
"invalidEmail": "請輸入有效的電子郵件地址",
|
||||||
|
"loginFailed": "登入失敗,請稍後再試"
|
||||||
|
},
|
||||||
|
"welcome": {
|
||||||
|
"title": "專案控制中心",
|
||||||
|
"subtitle": "管理您的專案、任務和團隊"
|
||||||
|
}
|
||||||
|
}
|
||||||
102
frontend/public/locales/zh-TW/common.json
Normal file
102
frontend/public/locales/zh-TW/common.json
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"buttons": {
|
||||||
|
"save": "儲存",
|
||||||
|
"cancel": "取消",
|
||||||
|
"delete": "刪除",
|
||||||
|
"edit": "編輯",
|
||||||
|
"create": "建立",
|
||||||
|
"close": "關閉",
|
||||||
|
"confirm": "確認",
|
||||||
|
"back": "返回",
|
||||||
|
"next": "下一步",
|
||||||
|
"previous": "上一步",
|
||||||
|
"submit": "提交",
|
||||||
|
"reset": "重置",
|
||||||
|
"search": "搜尋",
|
||||||
|
"filter": "篩選",
|
||||||
|
"export": "匯出",
|
||||||
|
"import": "匯入",
|
||||||
|
"refresh": "重新整理",
|
||||||
|
"add": "新增",
|
||||||
|
"remove": "移除",
|
||||||
|
"view": "檢視",
|
||||||
|
"download": "下載",
|
||||||
|
"upload": "上傳"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"loading": "載入中...",
|
||||||
|
"noData": "暫無資料",
|
||||||
|
"required": "必填",
|
||||||
|
"optional": "選填",
|
||||||
|
"all": "全部",
|
||||||
|
"none": "無",
|
||||||
|
"yes": "是",
|
||||||
|
"no": "否",
|
||||||
|
"active": "啟用",
|
||||||
|
"inactive": "停用",
|
||||||
|
"enabled": "已啟用",
|
||||||
|
"disabled": "已停用",
|
||||||
|
"actions": "操作",
|
||||||
|
"details": "詳情",
|
||||||
|
"description": "描述",
|
||||||
|
"name": "名稱",
|
||||||
|
"title": "標題",
|
||||||
|
"status": "狀態",
|
||||||
|
"type": "類型",
|
||||||
|
"date": "日期",
|
||||||
|
"time": "時間",
|
||||||
|
"createdAt": "建立時間",
|
||||||
|
"updatedAt": "更新時間",
|
||||||
|
"createdBy": "建立者",
|
||||||
|
"assignee": "負責人",
|
||||||
|
"selectAssignee": "選擇負責人...",
|
||||||
|
"searchUsers": "搜尋使用者...",
|
||||||
|
"noUsersFound": "找不到使用者",
|
||||||
|
"typeToSearch": "輸入以搜尋使用者"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"success": "操作成功",
|
||||||
|
"error": "操作失敗",
|
||||||
|
"confirmDelete": "確定要刪除嗎?此操作無法復原。",
|
||||||
|
"unsavedChanges": "您有未儲存的變更,確定要離開嗎?",
|
||||||
|
"networkError": "網路連線錯誤,請稍後再試",
|
||||||
|
"sessionExpired": "工作階段已過期,請重新登入",
|
||||||
|
"permissionDenied": "您沒有權限執行此操作",
|
||||||
|
"notFound": "找不到請求的資源"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "此欄位為必填",
|
||||||
|
"email": "請輸入有效的電子郵件地址",
|
||||||
|
"minLength": "至少需要 {{min}} 個字元",
|
||||||
|
"maxLength": "最多 {{max}} 個字元",
|
||||||
|
"invalidFormat": "格式不正確"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "儀表板",
|
||||||
|
"spaces": "工作空間",
|
||||||
|
"projects": "專案",
|
||||||
|
"tasks": "任務",
|
||||||
|
"workload": "工作負載",
|
||||||
|
"health": "專案健康度",
|
||||||
|
"audit": "稽核日誌",
|
||||||
|
"settings": "設定",
|
||||||
|
"logout": "登出"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"switch": "切換語言",
|
||||||
|
"zhTW": "繁體中文",
|
||||||
|
"en": "English"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "通知",
|
||||||
|
"markAllRead": "全部標為已讀",
|
||||||
|
"noNotifications": "沒有通知",
|
||||||
|
"viewAll": "查看全部"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"page": "第 {{page}} 頁",
|
||||||
|
"of": "共 {{total}} 頁",
|
||||||
|
"showing": "顯示 {{from}}-{{to}} 筆,共 {{total}} 筆",
|
||||||
|
"itemsPerPage": "每頁顯示"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
frontend/public/locales/zh-TW/dashboard.json
Normal file
47
frontend/public/locales/zh-TW/dashboard.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"title": "儀表板",
|
||||||
|
"welcome": "歡迎回來,{{name}}",
|
||||||
|
"stats": {
|
||||||
|
"myTasks": "我的任務",
|
||||||
|
"overdueTasks": "逾期任務",
|
||||||
|
"completedTasks": "已完成任務",
|
||||||
|
"activeProjects": "進行中專案",
|
||||||
|
"teamMembers": "團隊成員",
|
||||||
|
"totalTasks": "總任務數",
|
||||||
|
"pendingReview": "待審核"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"recentActivity": "近期活動",
|
||||||
|
"upcomingDeadlines": "即將到期",
|
||||||
|
"myAssignedTasks": "指派給我的任務",
|
||||||
|
"projectOverview": "專案概覽",
|
||||||
|
"quickActions": "快速操作"
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"taskCreated": "建立了任務「{{task}}」",
|
||||||
|
"taskCompleted": "完成了任務「{{task}}」",
|
||||||
|
"taskAssigned": "將任務「{{task}}」指派給 {{assignee}}",
|
||||||
|
"commentAdded": "在任務「{{task}}」新增了留言",
|
||||||
|
"projectCreated": "建立了專案「{{project}}」",
|
||||||
|
"noRecentActivity": "暫無近期活動"
|
||||||
|
},
|
||||||
|
"deadlines": {
|
||||||
|
"today": "今天到期",
|
||||||
|
"tomorrow": "明天到期",
|
||||||
|
"thisWeek": "本週到期",
|
||||||
|
"overdue": "已逾期",
|
||||||
|
"noUpcoming": "近期沒有待處理的截止日期"
|
||||||
|
},
|
||||||
|
"quickActions": {
|
||||||
|
"createTask": "建立任務",
|
||||||
|
"createProject": "建立專案",
|
||||||
|
"viewAllTasks": "查看所有任務",
|
||||||
|
"viewWorkload": "查看工作負載"
|
||||||
|
},
|
||||||
|
"widgets": {
|
||||||
|
"tasksByStatus": "依狀態分類的任務",
|
||||||
|
"tasksByPriority": "依優先順序分類的任務",
|
||||||
|
"projectProgress": "專案進度",
|
||||||
|
"teamWorkload": "團隊工作負載"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
frontend/public/locales/zh-TW/health.json
Normal file
48
frontend/public/locales/zh-TW/health.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"title": "專案健康度",
|
||||||
|
"subtitle": "監控專案的整體健康狀況",
|
||||||
|
"overall": {
|
||||||
|
"title": "整體健康度",
|
||||||
|
"healthy": "健康",
|
||||||
|
"atRisk": "風險中",
|
||||||
|
"critical": "危急"
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"schedule": "進度",
|
||||||
|
"budget": "預算",
|
||||||
|
"scope": "範圍",
|
||||||
|
"quality": "品質",
|
||||||
|
"resources": "資源"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"onTrack": "正常進行",
|
||||||
|
"delayed": "延遲",
|
||||||
|
"ahead": "超前",
|
||||||
|
"overBudget": "超支",
|
||||||
|
"underBudget": "低於預算"
|
||||||
|
},
|
||||||
|
"indicators": {
|
||||||
|
"title": "健康指標",
|
||||||
|
"taskCompletion": "任務完成率",
|
||||||
|
"onTimeDelivery": "準時交付率",
|
||||||
|
"blockedTasks": "阻擋任務數",
|
||||||
|
"overdueRate": "逾期率",
|
||||||
|
"velocityTrend": "速度趨勢"
|
||||||
|
},
|
||||||
|
"risks": {
|
||||||
|
"title": "風險",
|
||||||
|
"high": "高風險",
|
||||||
|
"medium": "中風險",
|
||||||
|
"low": "低風險",
|
||||||
|
"mitigated": "已緩解"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"viewDetails": "查看詳情",
|
||||||
|
"exportReport": "匯出報告",
|
||||||
|
"setAlert": "設定警示"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "沒有健康度資料",
|
||||||
|
"description": "專案需要更多資料才能顯示健康度指標"
|
||||||
|
}
|
||||||
|
}
|
||||||
50
frontend/public/locales/zh-TW/projects.json
Normal file
50
frontend/public/locales/zh-TW/projects.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"title": "專案",
|
||||||
|
"createProject": "建立專案",
|
||||||
|
"editProject": "編輯專案",
|
||||||
|
"deleteProject": "刪除專案",
|
||||||
|
"projectSettings": "專案設定",
|
||||||
|
"fields": {
|
||||||
|
"name": "名稱",
|
||||||
|
"namePlaceholder": "輸入專案名稱",
|
||||||
|
"description": "描述",
|
||||||
|
"descriptionPlaceholder": "輸入專案描述",
|
||||||
|
"status": "狀態",
|
||||||
|
"startDate": "開始日期",
|
||||||
|
"endDate": "結束日期",
|
||||||
|
"owner": "專案負責人",
|
||||||
|
"space": "工作空間"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"planning": "規劃中",
|
||||||
|
"active": "進行中",
|
||||||
|
"on_hold": "暫停",
|
||||||
|
"completed": "已完成",
|
||||||
|
"cancelled": "已取消"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"overview": "概覽",
|
||||||
|
"tasks": "任務",
|
||||||
|
"members": "成員",
|
||||||
|
"settings": "設定",
|
||||||
|
"files": "檔案",
|
||||||
|
"activity": "活動紀錄"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"totalTasks": "總任務數",
|
||||||
|
"completedTasks": "已完成",
|
||||||
|
"inProgress": "進行中",
|
||||||
|
"overdue": "逾期",
|
||||||
|
"progress": "整體進度"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"created": "專案已建立",
|
||||||
|
"updated": "專案已更新",
|
||||||
|
"deleted": "專案已刪除",
|
||||||
|
"confirmDelete": "確定要刪除此專案嗎?此操作將刪除所有相關任務。"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "沒有專案",
|
||||||
|
"description": "建立您的第一個專案來開始管理任務"
|
||||||
|
}
|
||||||
|
}
|
||||||
73
frontend/public/locales/zh-TW/settings.json
Normal file
73
frontend/public/locales/zh-TW/settings.json
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"title": "設定",
|
||||||
|
"projectSettings": "專案設定",
|
||||||
|
"tabs": {
|
||||||
|
"general": "一般",
|
||||||
|
"members": "成員",
|
||||||
|
"customFields": "自訂欄位",
|
||||||
|
"notifications": "通知",
|
||||||
|
"integrations": "整合",
|
||||||
|
"danger": "危險區域"
|
||||||
|
},
|
||||||
|
"general": {
|
||||||
|
"title": "一般設定",
|
||||||
|
"projectName": "專案名稱",
|
||||||
|
"description": "描述",
|
||||||
|
"status": "狀態",
|
||||||
|
"visibility": "可見性",
|
||||||
|
"public": "公開",
|
||||||
|
"private": "私人"
|
||||||
|
},
|
||||||
|
"members": {
|
||||||
|
"title": "成員管理",
|
||||||
|
"invite": "邀請成員",
|
||||||
|
"inviteByEmail": "透過電子郵件邀請",
|
||||||
|
"emailPlaceholder": "輸入電子郵件地址",
|
||||||
|
"role": "角色",
|
||||||
|
"changeRole": "變更角色",
|
||||||
|
"remove": "移除成員",
|
||||||
|
"confirmRemove": "確定要移除此成員嗎?"
|
||||||
|
},
|
||||||
|
"customFields": {
|
||||||
|
"title": "自訂欄位",
|
||||||
|
"add": "新增欄位",
|
||||||
|
"edit": "編輯欄位",
|
||||||
|
"delete": "刪除欄位",
|
||||||
|
"fieldName": "欄位名稱",
|
||||||
|
"fieldType": "欄位類型",
|
||||||
|
"required": "必填",
|
||||||
|
"types": {
|
||||||
|
"text": "文字",
|
||||||
|
"number": "數字",
|
||||||
|
"date": "日期",
|
||||||
|
"select": "下拉選單",
|
||||||
|
"multiSelect": "多選",
|
||||||
|
"checkbox": "核取方塊"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "通知設定",
|
||||||
|
"email": "電子郵件通知",
|
||||||
|
"inApp": "應用內通知",
|
||||||
|
"taskAssigned": "任務指派給我時",
|
||||||
|
"taskCompleted": "任務完成時",
|
||||||
|
"commentAdded": "新增留言時",
|
||||||
|
"dueDateApproaching": "截止日期即將到來時"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"title": "危險區域",
|
||||||
|
"archive": "封存專案",
|
||||||
|
"archiveDescription": "封存此專案。封存後專案將變為唯讀。",
|
||||||
|
"delete": "刪除專案",
|
||||||
|
"deleteDescription": "永久刪除此專案及其所有資料。此操作無法復原。",
|
||||||
|
"confirmArchive": "確定要封存此專案嗎?",
|
||||||
|
"confirmDelete": "確定要刪除此專案嗎?請輸入專案名稱以確認:",
|
||||||
|
"typeToConfirm": "輸入「{{name}}」以確認"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"saved": "設定已儲存",
|
||||||
|
"memberInvited": "已發送邀請",
|
||||||
|
"memberRemoved": "成員已移除",
|
||||||
|
"roleChanged": "角色已變更"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
frontend/public/locales/zh-TW/spaces.json
Normal file
39
frontend/public/locales/zh-TW/spaces.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"title": "工作空間",
|
||||||
|
"createSpace": "建立工作空間",
|
||||||
|
"editSpace": "編輯工作空間",
|
||||||
|
"deleteSpace": "刪除工作空間",
|
||||||
|
"fields": {
|
||||||
|
"name": "名稱",
|
||||||
|
"namePlaceholder": "輸入工作空間名稱",
|
||||||
|
"description": "描述",
|
||||||
|
"descriptionPlaceholder": "輸入工作空間描述",
|
||||||
|
"icon": "圖示",
|
||||||
|
"color": "顏色"
|
||||||
|
},
|
||||||
|
"members": {
|
||||||
|
"title": "成員",
|
||||||
|
"add": "新增成員",
|
||||||
|
"remove": "移除成員",
|
||||||
|
"role": "角色",
|
||||||
|
"owner": "擁有者",
|
||||||
|
"admin": "管理員",
|
||||||
|
"member": "成員",
|
||||||
|
"viewer": "檢視者"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"projects": "專案數",
|
||||||
|
"members": "成員數",
|
||||||
|
"tasks": "任務數"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"created": "工作空間已建立",
|
||||||
|
"updated": "工作空間已更新",
|
||||||
|
"deleted": "工作空間已刪除",
|
||||||
|
"confirmDelete": "確定要刪除此工作空間嗎?此操作將刪除所有相關專案和任務。"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "沒有工作空間",
|
||||||
|
"description": "建立您的第一個工作空間來組織專案"
|
||||||
|
}
|
||||||
|
}
|
||||||
134
frontend/public/locales/zh-TW/tasks.json
Normal file
134
frontend/public/locales/zh-TW/tasks.json
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
{
|
||||||
|
"title": "任務",
|
||||||
|
"createTask": "建立任務",
|
||||||
|
"editTask": "編輯任務",
|
||||||
|
"deleteTask": "刪除任務",
|
||||||
|
"taskDetails": "任務詳情",
|
||||||
|
"fields": {
|
||||||
|
"title": "標題",
|
||||||
|
"titlePlaceholder": "輸入任務標題",
|
||||||
|
"description": "描述",
|
||||||
|
"descriptionPlaceholder": "輸入任務描述",
|
||||||
|
"status": "狀態",
|
||||||
|
"priority": "優先順序",
|
||||||
|
"assignee": "負責人",
|
||||||
|
"dueDate": "截止日期",
|
||||||
|
"startDate": "開始日期",
|
||||||
|
"estimatedHours": "預估工時",
|
||||||
|
"actualHours": "實際工時",
|
||||||
|
"progress": "進度",
|
||||||
|
"tags": "標籤",
|
||||||
|
"parent": "父任務",
|
||||||
|
"subtasks": "子任務",
|
||||||
|
"attachments": "附件",
|
||||||
|
"comments": "留言",
|
||||||
|
"watchers": "關注者",
|
||||||
|
"blockers": "阻擋項目"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"todo": "待處理",
|
||||||
|
"in_progress": "進行中",
|
||||||
|
"review": "審核中",
|
||||||
|
"done": "已完成",
|
||||||
|
"cancelled": "已取消",
|
||||||
|
"blocked": "被阻擋"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"low": "低",
|
||||||
|
"medium": "中",
|
||||||
|
"high": "高",
|
||||||
|
"urgent": "緊急"
|
||||||
|
},
|
||||||
|
"views": {
|
||||||
|
"list": "列表",
|
||||||
|
"kanban": "看板",
|
||||||
|
"calendar": "日曆",
|
||||||
|
"gantt": "甘特圖",
|
||||||
|
"timeline": "時間軸"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"all": "全部任務",
|
||||||
|
"myTasks": "我的任務",
|
||||||
|
"unassigned": "未指派",
|
||||||
|
"overdue": "逾期",
|
||||||
|
"dueThisWeek": "本週到期",
|
||||||
|
"highPriority": "高優先順序",
|
||||||
|
"recentlyUpdated": "最近更新"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"sortBy": "排序方式",
|
||||||
|
"dueDate": "截止日期",
|
||||||
|
"priority": "優先順序",
|
||||||
|
"status": "狀態",
|
||||||
|
"title": "標題",
|
||||||
|
"createdAt": "建立時間",
|
||||||
|
"updatedAt": "更新時間",
|
||||||
|
"ascending": "升序",
|
||||||
|
"descending": "降序"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"assign": "指派",
|
||||||
|
"reassign": "重新指派",
|
||||||
|
"changeStatus": "變更狀態",
|
||||||
|
"changePriority": "變更優先順序",
|
||||||
|
"addSubtask": "新增子任務",
|
||||||
|
"addComment": "新增留言",
|
||||||
|
"addAttachment": "新增附件",
|
||||||
|
"addWatcher": "新增關注者",
|
||||||
|
"removeWatcher": "移除關注者",
|
||||||
|
"duplicate": "複製任務",
|
||||||
|
"archive": "封存",
|
||||||
|
"restore": "還原",
|
||||||
|
"moveToProject": "移至專案"
|
||||||
|
},
|
||||||
|
"subtasks": {
|
||||||
|
"title": "子任務",
|
||||||
|
"add": "新增子任務",
|
||||||
|
"placeholder": "輸入子任務標題",
|
||||||
|
"completed": "已完成 {{count}} / {{total}}",
|
||||||
|
"empty": "沒有子任務"
|
||||||
|
},
|
||||||
|
"comments": {
|
||||||
|
"title": "留言",
|
||||||
|
"add": "新增留言",
|
||||||
|
"placeholder": "輸入您的留言...",
|
||||||
|
"edited": "已編輯",
|
||||||
|
"delete": "刪除留言",
|
||||||
|
"confirmDelete": "確定要刪除此留言嗎?",
|
||||||
|
"empty": "還沒有留言",
|
||||||
|
"reply": "回覆"
|
||||||
|
},
|
||||||
|
"attachments": {
|
||||||
|
"title": "附件",
|
||||||
|
"add": "新增附件",
|
||||||
|
"upload": "上傳檔案",
|
||||||
|
"dragDrop": "拖放檔案至此或點擊上傳",
|
||||||
|
"maxSize": "最大檔案大小:{{size}}MB",
|
||||||
|
"downloading": "下載中...",
|
||||||
|
"empty": "沒有附件"
|
||||||
|
},
|
||||||
|
"blockers": {
|
||||||
|
"title": "阻擋項目",
|
||||||
|
"add": "新增阻擋項目",
|
||||||
|
"blockedBy": "被以下任務阻擋",
|
||||||
|
"blocking": "正在阻擋以下任務",
|
||||||
|
"remove": "移除阻擋關係",
|
||||||
|
"empty": "沒有阻擋項目"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"created": "任務已建立",
|
||||||
|
"updated": "任務已更新",
|
||||||
|
"deleted": "任務已刪除",
|
||||||
|
"statusChanged": "狀態已變更為「{{status}}」",
|
||||||
|
"assigned": "已指派給 {{assignee}}",
|
||||||
|
"unassigned": "已取消指派",
|
||||||
|
"commentAdded": "留言已新增",
|
||||||
|
"attachmentUploaded": "附件已上傳",
|
||||||
|
"confirmDelete": "確定要刪除此任務嗎?此操作無法復原。"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "沒有任務",
|
||||||
|
"description": "目前沒有任務。建立您的第一個任務開始吧!",
|
||||||
|
"filtered": "沒有符合篩選條件的任務"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
frontend/public/locales/zh-TW/workload.json
Normal file
42
frontend/public/locales/zh-TW/workload.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"title": "工作負載",
|
||||||
|
"subtitle": "檢視團隊成員的工作負載分佈",
|
||||||
|
"filters": {
|
||||||
|
"project": "專案",
|
||||||
|
"allProjects": "所有專案",
|
||||||
|
"dateRange": "日期範圍",
|
||||||
|
"thisWeek": "本週",
|
||||||
|
"thisMonth": "本月",
|
||||||
|
"custom": "自訂"
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"totalHours": "總工時",
|
||||||
|
"assignedTasks": "指派任務數",
|
||||||
|
"completedTasks": "已完成任務",
|
||||||
|
"overdueTasks": "逾期任務",
|
||||||
|
"utilization": "使用率"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"hoursPerDay": "每日工時",
|
||||||
|
"taskDistribution": "任務分佈",
|
||||||
|
"byProject": "依專案",
|
||||||
|
"byPriority": "依優先順序"
|
||||||
|
},
|
||||||
|
"team": {
|
||||||
|
"title": "團隊成員",
|
||||||
|
"member": "成員",
|
||||||
|
"allocated": "已分配",
|
||||||
|
"available": "可用",
|
||||||
|
"overloaded": "超載",
|
||||||
|
"underutilized": "低使用率"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"balanced": "平衡",
|
||||||
|
"overloaded": "超載",
|
||||||
|
"underutilized": "低使用率"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "沒有工作負載資料",
|
||||||
|
"description": "目前沒有足夠的資料來顯示工作負載"
|
||||||
|
}
|
||||||
|
}
|
||||||
139
frontend/src/components/LanguageSwitcher.tsx
Normal file
139
frontend/src/components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ code: 'zh-TW', label: '繁體中文', flag: '🇹🇼' },
|
||||||
|
{ code: 'en', label: 'English', flag: '🇺🇸' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type LanguageCode = (typeof languages)[number]['code']
|
||||||
|
|
||||||
|
export function LanguageSwitcher() {
|
||||||
|
const { i18n, t } = useTranslation('common')
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const currentLanguage = languages.find((lang) => lang.code === i18n.language) || languages[0]
|
||||||
|
|
||||||
|
const handleChangeLanguage = (langCode: LanguageCode) => {
|
||||||
|
i18n.changeLanguage(langCode)
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} style={styles.container}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
style={styles.button}
|
||||||
|
aria-label={t('language.switch')}
|
||||||
|
title={t('language.switch')}
|
||||||
|
>
|
||||||
|
<span style={styles.flag}>{currentLanguage.flag}</span>
|
||||||
|
<span style={styles.code}>{currentLanguage.code.toUpperCase()}</span>
|
||||||
|
<span style={styles.arrow}>{isOpen ? '\u25B2' : '\u25BC'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div style={styles.dropdown}>
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<button
|
||||||
|
key={lang.code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleChangeLanguage(lang.code)}
|
||||||
|
style={{
|
||||||
|
...styles.option,
|
||||||
|
...(currentLanguage.code === lang.code ? styles.optionActive : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={styles.flag}>{lang.flag}</span>
|
||||||
|
<span style={styles.label}>{lang.label}</span>
|
||||||
|
{currentLanguage.code === lang.code && <span style={styles.check}>✓</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
|
container: {
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#333',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
},
|
||||||
|
flag: {
|
||||||
|
fontSize: '16px',
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '12px',
|
||||||
|
},
|
||||||
|
arrow: {
|
||||||
|
fontSize: '8px',
|
||||||
|
color: '#666',
|
||||||
|
marginLeft: '2px',
|
||||||
|
},
|
||||||
|
dropdown: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
right: 0,
|
||||||
|
marginTop: '4px',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||||
|
zIndex: 1000,
|
||||||
|
minWidth: '150px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
option: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#333',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
},
|
||||||
|
optionActive: {
|
||||||
|
backgroundColor: '#f0f7ff',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
check: {
|
||||||
|
color: '#0066cc',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LanguageSwitcher
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { NotificationBell } from './NotificationBell'
|
import { NotificationBell } from './NotificationBell'
|
||||||
|
import { LanguageSwitcher } from './LanguageSwitcher'
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({ children }: LayoutProps) {
|
export default function Layout({ children }: LayoutProps) {
|
||||||
|
const { t } = useTranslation('common')
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -17,11 +20,11 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', label: 'Dashboard' },
|
{ path: '/', labelKey: 'nav.dashboard' },
|
||||||
{ path: '/spaces', label: 'Spaces' },
|
{ path: '/spaces', labelKey: 'nav.spaces' },
|
||||||
{ path: '/workload', label: 'Workload' },
|
{ path: '/workload', labelKey: 'nav.workload' },
|
||||||
{ path: '/project-health', label: 'Health' },
|
{ path: '/project-health', labelKey: 'nav.health' },
|
||||||
...(user?.is_system_admin ? [{ path: '/audit', label: 'Audit' }] : []),
|
...(user?.is_system_admin ? [{ path: '/audit', labelKey: 'nav.audit' }] : []),
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,19 +44,20 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
...(location.pathname === item.path ? styles.navItemActive : {}),
|
...(location.pathname === item.path ? styles.navItemActive : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.label}
|
{t(item.labelKey)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.headerRight}>
|
<div style={styles.headerRight}>
|
||||||
|
<LanguageSwitcher />
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
<span style={styles.userName}>{user?.name}</span>
|
<span style={styles.userName}>{user?.name}</span>
|
||||||
{user?.is_system_admin && (
|
{user?.is_system_admin && (
|
||||||
<span style={styles.badge}>Admin</span>
|
<span style={styles.badge}>Admin</span>
|
||||||
)}
|
)}
|
||||||
<button onClick={handleLogout} style={styles.logoutButton}>
|
<button onClick={handleLogout} style={styles.logoutButton}>
|
||||||
Logout
|
{t('nav.logout')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
55
frontend/src/i18n/index.ts
Normal file
55
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import i18n from 'i18next'
|
||||||
|
import { initReactI18next } from 'react-i18next'
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||||
|
import HttpBackend from 'i18next-http-backend'
|
||||||
|
|
||||||
|
const SUPPORTED_LANGUAGES = ['zh-TW', 'en'] as const
|
||||||
|
const DEFAULT_LANGUAGE = 'zh-TW'
|
||||||
|
|
||||||
|
// Namespaces for translation files
|
||||||
|
export const NAMESPACES = [
|
||||||
|
'common',
|
||||||
|
'auth',
|
||||||
|
'dashboard',
|
||||||
|
'tasks',
|
||||||
|
'projects',
|
||||||
|
'spaces',
|
||||||
|
'settings',
|
||||||
|
'workload',
|
||||||
|
'health',
|
||||||
|
'audit',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]
|
||||||
|
export type Namespace = (typeof NAMESPACES)[number]
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(HttpBackend)
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
fallbackLng: DEFAULT_LANGUAGE,
|
||||||
|
supportedLngs: SUPPORTED_LANGUAGES,
|
||||||
|
defaultNS: 'common',
|
||||||
|
ns: NAMESPACES,
|
||||||
|
|
||||||
|
backend: {
|
||||||
|
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
||||||
|
},
|
||||||
|
|
||||||
|
detection: {
|
||||||
|
order: ['localStorage', 'navigator'],
|
||||||
|
lookupLocalStorage: 'i18nextLng',
|
||||||
|
caches: ['localStorage'],
|
||||||
|
},
|
||||||
|
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false, // React already escapes values
|
||||||
|
},
|
||||||
|
|
||||||
|
react: {
|
||||||
|
useSuspense: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default i18n
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, { Suspense } from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
@@ -6,20 +6,36 @@ import { AuthProvider } from './contexts/AuthContext'
|
|||||||
import { NotificationProvider } from './contexts/NotificationContext'
|
import { NotificationProvider } from './contexts/NotificationContext'
|
||||||
import { ProjectSyncProvider } from './contexts/ProjectSyncContext'
|
import { ProjectSyncProvider } from './contexts/ProjectSyncContext'
|
||||||
import { ToastProvider } from './contexts/ToastContext'
|
import { ToastProvider } from './contexts/ToastContext'
|
||||||
|
import './i18n'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
|
// Loading fallback for i18n
|
||||||
|
const LoadingFallback = () => (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
fontFamily: 'system-ui, -apple-system, sans-serif'
|
||||||
|
}}>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<AuthProvider>
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
<ToastProvider>
|
<AuthProvider>
|
||||||
<NotificationProvider>
|
<ToastProvider>
|
||||||
<ProjectSyncProvider>
|
<NotificationProvider>
|
||||||
<App />
|
<ProjectSyncProvider>
|
||||||
</ProjectSyncProvider>
|
<App />
|
||||||
</NotificationProvider>
|
</ProjectSyncProvider>
|
||||||
</ToastProvider>
|
</NotificationProvider>
|
||||||
</AuthProvider>
|
</ToastProvider>
|
||||||
</BrowserRouter>
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</Suspense>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { SkeletonTable } from '../components/Skeleton'
|
import { SkeletonTable } from '../components/Skeleton'
|
||||||
import { auditService, AuditLog, AuditLogFilters, IntegrityCheckResponse } from '../services/audit'
|
import { auditService, AuditLog, AuditLogFilters, IntegrityCheckResponse } from '../services/audit'
|
||||||
@@ -280,6 +281,7 @@ function IntegrityVerificationModal({ result, isLoading, error, onClose }: Integ
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AuditPage() {
|
export default function AuditPage() {
|
||||||
|
const { t } = useTranslation('audit')
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
@@ -466,15 +468,15 @@ export default function AuditPage() {
|
|||||||
if (!user?.is_system_admin) {
|
if (!user?.is_system_admin) {
|
||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<h2>Access Denied</h2>
|
<h2>{t('common:messages.permissionDenied')}</h2>
|
||||||
<p>You need administrator privileges to view audit logs.</p>
|
<p>{t('common:messages.permissionDenied')}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<h2>Audit Logs</h2>
|
<h2>{t('title')}</h2>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div style={styles.filtersContainer}>
|
<div style={styles.filtersContainer}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { dashboardApi, DashboardResponse } from '../services/dashboard'
|
import { dashboardApi, DashboardResponse } from '../services/dashboard'
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
import { Skeleton } from '../components/Skeleton'
|
import { Skeleton } from '../components/Skeleton'
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
|
const { t } = useTranslation('dashboard')
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [data, setData] = useState<DashboardResponse | null>(null)
|
const [data, setData] = useState<DashboardResponse | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -22,7 +24,7 @@ export default function Dashboard() {
|
|||||||
const response = await dashboardApi.getDashboard()
|
const response = await dashboardApi.getDashboard()
|
||||||
setData(response)
|
setData(response)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to load dashboard data. Please try again.')
|
setError(t('common:messages.networkError'))
|
||||||
console.error('Dashboard fetch error:', err)
|
console.error('Dashboard fetch error:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -93,19 +95,19 @@ export default function Dashboard() {
|
|||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<div style={styles.welcomeSection}>
|
<div style={styles.welcomeSection}>
|
||||||
<h1 style={styles.welcomeTitle}>Welcome, {user?.name}!</h1>
|
<h1 style={styles.welcomeTitle}>{t('welcome', { name: user?.name })}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.errorCard}>
|
<div style={styles.errorCard}>
|
||||||
<div style={styles.errorIcon}>!</div>
|
<div style={styles.errorIcon}>!</div>
|
||||||
<h3 style={styles.errorTitle}>Unable to Load Dashboard</h3>
|
<h3 style={styles.errorTitle}>{t('common:messages.error')}</h3>
|
||||||
<p style={styles.errorMessage}>{error}</p>
|
<p style={styles.errorMessage}>{error}</p>
|
||||||
<button
|
<button
|
||||||
style={styles.retryButton}
|
style={styles.retryButton}
|
||||||
onClick={fetchDashboard}
|
onClick={fetchDashboard}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Try Again
|
{t('common:buttons.refresh')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,9 +119,9 @@ export default function Dashboard() {
|
|||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
{/* Welcome Section */}
|
{/* Welcome Section */}
|
||||||
<div style={styles.welcomeSection}>
|
<div style={styles.welcomeSection}>
|
||||||
<h1 style={styles.welcomeTitle}>Welcome, {user?.name}!</h1>
|
<h1 style={styles.welcomeTitle}>{t('welcome', { name: user?.name })}</h1>
|
||||||
<p style={styles.welcomeSubtitle}>
|
<p style={styles.welcomeSubtitle}>
|
||||||
Here is your work overview for today
|
{t('sections.projectOverview')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,27 +132,27 @@ export default function Dashboard() {
|
|||||||
<StatisticsCard
|
<StatisticsCard
|
||||||
icon="✓"
|
icon="✓"
|
||||||
value={data.task_stats.assigned_count}
|
value={data.task_stats.assigned_count}
|
||||||
label="My Tasks"
|
label={t('stats.myTasks')}
|
||||||
color="#2196f3"
|
color="#2196f3"
|
||||||
/>
|
/>
|
||||||
<StatisticsCard
|
<StatisticsCard
|
||||||
icon="⏰"
|
icon="⏰"
|
||||||
value={data.task_stats.due_this_week}
|
value={data.task_stats.due_this_week}
|
||||||
label="Due This Week"
|
label={t('deadlines.thisWeek')}
|
||||||
color="#ff9800"
|
color="#ff9800"
|
||||||
highlight={data.task_stats.due_this_week > 0}
|
highlight={data.task_stats.due_this_week > 0}
|
||||||
/>
|
/>
|
||||||
<StatisticsCard
|
<StatisticsCard
|
||||||
icon="⚠"
|
icon="⚠"
|
||||||
value={data.task_stats.overdue_count}
|
value={data.task_stats.overdue_count}
|
||||||
label="Overdue"
|
label={t('deadlines.overdue')}
|
||||||
color="#f44336"
|
color="#f44336"
|
||||||
highlight={data.task_stats.overdue_count > 0}
|
highlight={data.task_stats.overdue_count > 0}
|
||||||
/>
|
/>
|
||||||
<StatisticsCard
|
<StatisticsCard
|
||||||
icon="✅"
|
icon="✅"
|
||||||
value={data.task_stats.completion_rate}
|
value={data.task_stats.completion_rate}
|
||||||
label="Completion Rate"
|
label={t('stats.completedTasks')}
|
||||||
color="#4caf50"
|
color="#4caf50"
|
||||||
suffix="%"
|
suffix="%"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState, FormEvent } from 'react'
|
import { useState, FormEvent } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { LanguageSwitcher } from '../components/LanguageSwitcher'
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
|
const { t } = useTranslation('auth')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -21,11 +24,11 @@ export default function Login() {
|
|||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { status?: number } }
|
const error = err as { response?: { status?: number } }
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
setError('Invalid email or password')
|
setError(t('errors.invalidCredentials'))
|
||||||
} else if (error.response?.status === 503) {
|
} else if (error.response?.status === 503) {
|
||||||
setError('Authentication service temporarily unavailable')
|
setError(t('errors.loginFailed'))
|
||||||
} else {
|
} else {
|
||||||
setError('An error occurred. Please try again.')
|
setError(t('errors.loginFailed'))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -34,16 +37,19 @@ export default function Login() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
|
<div style={styles.languageSwitcherWrapper}>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
<div style={styles.card}>
|
<div style={styles.card}>
|
||||||
<h1 style={styles.title}>Project Control</h1>
|
<h1 style={styles.title}>{t('welcome.title')}</h1>
|
||||||
<p style={styles.subtitle}>Sign in to your account</p>
|
<p style={styles.subtitle}>{t('login.subtitle')}</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} style={styles.form}>
|
<form onSubmit={handleSubmit} style={styles.form}>
|
||||||
{error && <div style={styles.error}>{error}</div>}
|
{error && <div style={styles.error}>{error}</div>}
|
||||||
|
|
||||||
<div style={styles.field}>
|
<div style={styles.field}>
|
||||||
<label htmlFor="email" style={styles.label}>
|
<label htmlFor="email" style={styles.label}>
|
||||||
Email
|
{t('login.email')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
@@ -52,14 +58,14 @@ export default function Login() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
className="login-input"
|
className="login-input"
|
||||||
placeholder="your.email@company.com"
|
placeholder={t('login.emailPlaceholder')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.field}>
|
<div style={styles.field}>
|
||||||
<label htmlFor="password" style={styles.label}>
|
<label htmlFor="password" style={styles.label}>
|
||||||
Password
|
{t('login.password')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
@@ -68,7 +74,7 @@ export default function Login() {
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
className="login-input"
|
className="login-input"
|
||||||
placeholder="Enter your password"
|
placeholder={t('login.passwordPlaceholder')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +84,7 @@ export default function Login() {
|
|||||||
style={styles.button}
|
style={styles.button}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? 'Signing in...' : 'Sign in'}
|
{loading ? t('login.loggingIn') : t('login.submit')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,6 +99,12 @@ const styles: { [key: string]: React.CSSProperties } = {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: '#f5f5f5',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
languageSwitcherWrapper: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '20px',
|
||||||
|
right: '20px',
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: 'white',
|
backgroundColor: 'white',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { useToast } from '../contexts/ToastContext'
|
import { useToast } from '../contexts/ToastContext'
|
||||||
import { SkeletonGrid } from '../components/Skeleton'
|
import { SkeletonGrid } from '../components/Skeleton'
|
||||||
@@ -15,6 +16,7 @@ interface Space {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Spaces() {
|
export default function Spaces() {
|
||||||
|
const { t } = useTranslation('spaces')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const [spaces, setSpaces] = useState<Space[]>([])
|
const [spaces, setSpaces] = useState<Space[]>([])
|
||||||
@@ -53,7 +55,7 @@ export default function Spaces() {
|
|||||||
setSpaces(response.data)
|
setSpaces(response.data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load spaces:', err)
|
console.error('Failed to load spaces:', err)
|
||||||
showToast('Failed to load spaces', 'error')
|
showToast(t('common:messages.error'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -68,10 +70,10 @@ export default function Spaces() {
|
|||||||
setShowCreateModal(false)
|
setShowCreateModal(false)
|
||||||
setNewSpace({ name: '', description: '' })
|
setNewSpace({ name: '', description: '' })
|
||||||
loadSpaces()
|
loadSpaces()
|
||||||
showToast('Space created successfully', 'success')
|
showToast(t('messages.created'), 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to create space:', err)
|
console.error('Failed to create space:', err)
|
||||||
showToast('Failed to create space', 'error')
|
showToast(t('common:messages.error'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
}
|
}
|
||||||
@@ -92,9 +94,9 @@ export default function Spaces() {
|
|||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
<h1 style={styles.title}>Spaces</h1>
|
<h1 style={styles.title}>{t('title')}</h1>
|
||||||
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
|
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
|
||||||
+ New Space
|
+ {t('createSpace')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,21 +114,21 @@ export default function Spaces() {
|
|||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={`Open space: ${space.name}`}
|
aria-label={`${t('title')}: ${space.name}`}
|
||||||
>
|
>
|
||||||
<h3 style={styles.cardTitle}>{space.name}</h3>
|
<h3 style={styles.cardTitle}>{space.name}</h3>
|
||||||
<p style={styles.cardDescription}>
|
<p style={styles.cardDescription}>
|
||||||
{space.description || 'No description'}
|
{space.description || t('common:labels.noData')}
|
||||||
</p>
|
</p>
|
||||||
<div style={styles.cardMeta}>
|
<div style={styles.cardMeta}>
|
||||||
<span>Owner: {space.owner_name || 'Unknown'}</span>
|
<span>{t('members.owner')}: {space.owner_name || t('common:labels.none')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{spaces.length === 0 && (
|
{spaces.length === 0 && (
|
||||||
<div style={styles.empty}>
|
<div style={styles.empty}>
|
||||||
<p>No spaces yet. Create your first space to get started!</p>
|
<p>{t('empty.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -141,25 +143,25 @@ export default function Spaces() {
|
|||||||
aria-labelledby="create-space-title"
|
aria-labelledby="create-space-title"
|
||||||
>
|
>
|
||||||
<div style={styles.modal}>
|
<div style={styles.modal}>
|
||||||
<h2 id="create-space-title" style={styles.modalTitle}>Create New Space</h2>
|
<h2 id="create-space-title" style={styles.modalTitle}>{t('createSpace')}</h2>
|
||||||
<label htmlFor="space-name" style={styles.visuallyHidden}>
|
<label htmlFor="space-name" style={styles.visuallyHidden}>
|
||||||
Space name
|
{t('fields.name')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="space-name"
|
id="space-name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Space name"
|
placeholder={t('fields.namePlaceholder')}
|
||||||
value={newSpace.name}
|
value={newSpace.name}
|
||||||
onChange={(e) => setNewSpace({ ...newSpace, name: e.target.value })}
|
onChange={(e) => setNewSpace({ ...newSpace, name: e.target.value })}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<label htmlFor="space-description" style={styles.visuallyHidden}>
|
<label htmlFor="space-description" style={styles.visuallyHidden}>
|
||||||
Description
|
{t('fields.description')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="space-description"
|
id="space-description"
|
||||||
placeholder="Description (optional)"
|
placeholder={t('fields.descriptionPlaceholder')}
|
||||||
value={newSpace.description}
|
value={newSpace.description}
|
||||||
onChange={(e) => setNewSpace({ ...newSpace, description: e.target.value })}
|
onChange={(e) => setNewSpace({ ...newSpace, description: e.target.value })}
|
||||||
style={styles.textarea}
|
style={styles.textarea}
|
||||||
@@ -169,14 +171,14 @@ export default function Spaces() {
|
|||||||
onClick={() => setShowCreateModal(false)}
|
onClick={() => setShowCreateModal(false)}
|
||||||
style={styles.cancelButton}
|
style={styles.cancelButton}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common:buttons.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateSpace}
|
onClick={handleCreateSpace}
|
||||||
disabled={creating || !newSpace.name.trim()}
|
disabled={creating || !newSpace.name.trim()}
|
||||||
style={styles.submitButton}
|
style={styles.submitButton}
|
||||||
>
|
>
|
||||||
{creating ? 'Creating...' : 'Create'}
|
{creating ? t('common:labels.loading') : t('common:buttons.create')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { KanbanBoard } from '../components/KanbanBoard'
|
import { KanbanBoard } from '../components/KanbanBoard'
|
||||||
import { CalendarView } from '../components/CalendarView'
|
import { CalendarView } from '../components/CalendarView'
|
||||||
@@ -64,6 +65,7 @@ const saveColumnVisibility = (projectId: string, visibility: Record<string, bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Tasks() {
|
export default function Tasks() {
|
||||||
|
const { t } = useTranslation('tasks')
|
||||||
const { projectId } = useParams()
|
const { projectId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { subscribeToProject, unsubscribeFromProject, addTaskEventListener, isConnected } = useProjectSync()
|
const { subscribeToProject, unsubscribeFromProject, addTaskEventListener, isConnected } = useProjectSync()
|
||||||
@@ -472,14 +474,14 @@ export default function Tasks() {
|
|||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<div style={styles.breadcrumb}>
|
<div style={styles.breadcrumb}>
|
||||||
<span onClick={() => navigate('/spaces')} style={styles.breadcrumbLink}>
|
<span onClick={() => navigate('/spaces')} style={styles.breadcrumbLink}>
|
||||||
Spaces
|
{t('common:nav.spaces')}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.breadcrumbSeparator}>/</span>
|
<span style={styles.breadcrumbSeparator}>/</span>
|
||||||
<span
|
<span
|
||||||
onClick={() => navigate(`/spaces/${project?.space_id}`)}
|
onClick={() => navigate(`/spaces/${project?.space_id}`)}
|
||||||
style={styles.breadcrumbLink}
|
style={styles.breadcrumbLink}
|
||||||
>
|
>
|
||||||
Projects
|
{t('common:nav.projects')}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.breadcrumbSeparator}>/</span>
|
<span style={styles.breadcrumbSeparator}>/</span>
|
||||||
<span>{project?.title}</span>
|
<span>{project?.title}</span>
|
||||||
@@ -487,13 +489,13 @@ export default function Tasks() {
|
|||||||
|
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
<div style={styles.titleContainer}>
|
<div style={styles.titleContainer}>
|
||||||
<h1 style={styles.title}>Tasks</h1>
|
<h1 style={styles.title}>{t('title')}</h1>
|
||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
<span style={styles.liveIndicator} title="Real-time sync active">
|
<span style={styles.liveIndicator} title={t('common:labels.active')}>
|
||||||
● Live
|
● Live
|
||||||
</span>
|
</span>
|
||||||
) : projectId ? (
|
) : projectId ? (
|
||||||
<span style={styles.offlineIndicator} title="Real-time sync disconnected. Changes may not appear automatically.">
|
<span style={styles.offlineIndicator} title={t('common:labels.inactive')}>
|
||||||
○ Offline
|
○ Offline
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -507,9 +509,9 @@ export default function Tasks() {
|
|||||||
...styles.viewButton,
|
...styles.viewButton,
|
||||||
...(viewMode === 'list' ? styles.viewButtonActive : {}),
|
...(viewMode === 'list' ? styles.viewButtonActive : {}),
|
||||||
}}
|
}}
|
||||||
aria-label="List view"
|
aria-label={t('views.list')}
|
||||||
>
|
>
|
||||||
List
|
{t('views.list')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('kanban')}
|
onClick={() => setViewMode('kanban')}
|
||||||
@@ -517,9 +519,9 @@ export default function Tasks() {
|
|||||||
...styles.viewButton,
|
...styles.viewButton,
|
||||||
...(viewMode === 'kanban' ? styles.viewButtonActive : {}),
|
...(viewMode === 'kanban' ? styles.viewButtonActive : {}),
|
||||||
}}
|
}}
|
||||||
aria-label="Kanban view"
|
aria-label={t('views.kanban')}
|
||||||
>
|
>
|
||||||
Kanban
|
{t('views.kanban')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('calendar')}
|
onClick={() => setViewMode('calendar')}
|
||||||
@@ -527,9 +529,9 @@ export default function Tasks() {
|
|||||||
...styles.viewButton,
|
...styles.viewButton,
|
||||||
...(viewMode === 'calendar' ? styles.viewButtonActive : {}),
|
...(viewMode === 'calendar' ? styles.viewButtonActive : {}),
|
||||||
}}
|
}}
|
||||||
aria-label="Calendar view"
|
aria-label={t('views.calendar')}
|
||||||
>
|
>
|
||||||
Calendar
|
{t('views.calendar')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('gantt')}
|
onClick={() => setViewMode('gantt')}
|
||||||
@@ -537,9 +539,9 @@ export default function Tasks() {
|
|||||||
...styles.viewButton,
|
...styles.viewButton,
|
||||||
...(viewMode === 'gantt' ? styles.viewButtonActive : {}),
|
...(viewMode === 'gantt' ? styles.viewButtonActive : {}),
|
||||||
}}
|
}}
|
||||||
aria-label="Gantt view"
|
aria-label={t('views.gantt')}
|
||||||
>
|
>
|
||||||
Gantt
|
{t('views.gantt')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Column Visibility Toggle - only show when there are custom fields and in list view */}
|
{/* Column Visibility Toggle - only show when there are custom fields and in list view */}
|
||||||
@@ -548,13 +550,13 @@ export default function Tasks() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowColumnMenu(!showColumnMenu)}
|
onClick={() => setShowColumnMenu(!showColumnMenu)}
|
||||||
style={styles.columnMenuButton}
|
style={styles.columnMenuButton}
|
||||||
aria-label="Toggle columns"
|
aria-label={t('settings:customFields.title')}
|
||||||
>
|
>
|
||||||
Columns
|
{t('settings:customFields.title')}
|
||||||
</button>
|
</button>
|
||||||
{showColumnMenu && (
|
{showColumnMenu && (
|
||||||
<div style={styles.columnMenuDropdown}>
|
<div style={styles.columnMenuDropdown}>
|
||||||
<div style={styles.columnMenuHeader}>Show Custom Fields</div>
|
<div style={styles.columnMenuHeader}>{t('settings:customFields.title')}</div>
|
||||||
{customFields.map((field) => (
|
{customFields.map((field) => (
|
||||||
<label key={field.id} style={styles.columnMenuItem}>
|
<label key={field.id} style={styles.columnMenuItem}>
|
||||||
<input
|
<input
|
||||||
@@ -567,7 +569,7 @@ export default function Tasks() {
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
{customFields.length === 0 && (
|
{customFields.length === 0 && (
|
||||||
<div style={styles.columnMenuEmpty}>No custom fields</div>
|
<div style={styles.columnMenuEmpty}>{t('common:labels.noData')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -576,12 +578,12 @@ export default function Tasks() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/projects/${projectId}/settings`)}
|
onClick={() => navigate(`/projects/${projectId}/settings`)}
|
||||||
style={styles.settingsButton}
|
style={styles.settingsButton}
|
||||||
aria-label="Project settings"
|
aria-label={t('common:nav.settings')}
|
||||||
>
|
>
|
||||||
Settings
|
{t('common:nav.settings')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
|
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
|
||||||
+ New Task
|
+ {t('createTask')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -651,7 +653,7 @@ export default function Tasks() {
|
|||||||
|
|
||||||
{tasks.length === 0 && (
|
{tasks.length === 0 && (
|
||||||
<div style={styles.empty}>
|
<div style={styles.empty}>
|
||||||
<p>No tasks yet. Create your first task!</p>
|
<p>{t('empty.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -696,51 +698,51 @@ export default function Tasks() {
|
|||||||
aria-labelledby="create-task-title"
|
aria-labelledby="create-task-title"
|
||||||
>
|
>
|
||||||
<div style={styles.modal}>
|
<div style={styles.modal}>
|
||||||
<h2 id="create-task-title" style={styles.modalTitle}>Create New Task</h2>
|
<h2 id="create-task-title" style={styles.modalTitle}>{t('createTask')}</h2>
|
||||||
<label htmlFor="task-title" style={styles.visuallyHidden}>
|
<label htmlFor="task-title" style={styles.visuallyHidden}>
|
||||||
Task title
|
{t('fields.title')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="task-title"
|
id="task-title"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Task title"
|
placeholder={t('fields.titlePlaceholder')}
|
||||||
value={newTask.title}
|
value={newTask.title}
|
||||||
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
|
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<label htmlFor="task-description" style={styles.visuallyHidden}>
|
<label htmlFor="task-description" style={styles.visuallyHidden}>
|
||||||
Description
|
{t('fields.description')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="task-description"
|
id="task-description"
|
||||||
placeholder="Description (optional)"
|
placeholder={t('fields.descriptionPlaceholder')}
|
||||||
value={newTask.description}
|
value={newTask.description}
|
||||||
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
||||||
style={styles.textarea}
|
style={styles.textarea}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label style={styles.label}>Priority</label>
|
<label style={styles.label}>{t('fields.priority')}</label>
|
||||||
<select
|
<select
|
||||||
value={newTask.priority}
|
value={newTask.priority}
|
||||||
onChange={(e) => setNewTask({ ...newTask, priority: e.target.value })}
|
onChange={(e) => setNewTask({ ...newTask, priority: e.target.value })}
|
||||||
style={styles.select}
|
style={styles.select}
|
||||||
>
|
>
|
||||||
<option value="low">Low</option>
|
<option value="low">{t('priority.low')}</option>
|
||||||
<option value="medium">Medium</option>
|
<option value="medium">{t('priority.medium')}</option>
|
||||||
<option value="high">High</option>
|
<option value="high">{t('priority.high')}</option>
|
||||||
<option value="urgent">Urgent</option>
|
<option value="urgent">{t('priority.urgent')}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label style={styles.label}>Assignee</label>
|
<label style={styles.label}>{t('fields.assignee')}</label>
|
||||||
<UserSelect
|
<UserSelect
|
||||||
value={newTask.assignee_id}
|
value={newTask.assignee_id}
|
||||||
onChange={handleAssigneeChange}
|
onChange={handleAssigneeChange}
|
||||||
placeholder="Select assignee..."
|
placeholder={t('common:labels.selectAssignee')}
|
||||||
/>
|
/>
|
||||||
<div style={styles.fieldSpacer} />
|
<div style={styles.fieldSpacer} />
|
||||||
|
|
||||||
<label style={styles.label}>Due Date</label>
|
<label style={styles.label}>{t('fields.dueDate')}</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={newTask.due_date}
|
value={newTask.due_date}
|
||||||
@@ -748,7 +750,7 @@ export default function Tasks() {
|
|||||||
style={styles.input}
|
style={styles.input}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label style={styles.label}>Time Estimate (hours)</label>
|
<label style={styles.label}>{t('fields.estimatedHours')}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -763,7 +765,7 @@ export default function Tasks() {
|
|||||||
{customFields.filter((f) => f.field_type !== 'formula').length > 0 && (
|
{customFields.filter((f) => f.field_type !== 'formula').length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div style={styles.customFieldsDivider} />
|
<div style={styles.customFieldsDivider} />
|
||||||
<div style={styles.customFieldsTitle}>Custom Fields</div>
|
<div style={styles.customFieldsTitle}>{t('settings:customFields.title')}</div>
|
||||||
{customFields
|
{customFields
|
||||||
.filter((field) => field.field_type !== 'formula')
|
.filter((field) => field.field_type !== 'formula')
|
||||||
.map((field) => (
|
.map((field) => (
|
||||||
@@ -792,14 +794,14 @@ export default function Tasks() {
|
|||||||
|
|
||||||
<div style={styles.modalActions}>
|
<div style={styles.modalActions}>
|
||||||
<button onClick={() => setShowCreateModal(false)} style={styles.cancelButton}>
|
<button onClick={() => setShowCreateModal(false)} style={styles.cancelButton}>
|
||||||
Cancel
|
{t('common:buttons.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateTask}
|
onClick={handleCreateTask}
|
||||||
disabled={creating || !newTask.title.trim()}
|
disabled={creating || !newTask.title.trim()}
|
||||||
style={styles.submitButton}
|
style={styles.submitButton}
|
||||||
>
|
>
|
||||||
{creating ? 'Creating...' : 'Create'}
|
{creating ? t('common:labels.loading') : t('common:buttons.create')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { WorkloadHeatmap } from '../components/WorkloadHeatmap'
|
import { WorkloadHeatmap } from '../components/WorkloadHeatmap'
|
||||||
import { WorkloadUserDetail } from '../components/WorkloadUserDetail'
|
import { WorkloadUserDetail } from '../components/WorkloadUserDetail'
|
||||||
import { SkeletonTable } from '../components/Skeleton'
|
import { SkeletonTable } from '../components/Skeleton'
|
||||||
@@ -29,6 +30,7 @@ function formatWeekDisplay(date: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function WorkloadPage() {
|
export default function WorkloadPage() {
|
||||||
|
const { t } = useTranslation('workload')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [heatmapData, setHeatmapData] = useState<WorkloadHeatmapResponse | null>(null)
|
const [heatmapData, setHeatmapData] = useState<WorkloadHeatmapResponse | null>(null)
|
||||||
@@ -93,9 +95,9 @@ export default function WorkloadPage() {
|
|||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={styles.title}>Team Workload</h1>
|
<h1 style={styles.title}>{t('title')}</h1>
|
||||||
<p style={styles.subtitle}>
|
<p style={styles.subtitle}>
|
||||||
Monitor team capacity and task distribution
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,19 +145,19 @@ export default function WorkloadPage() {
|
|||||||
<div style={styles.statsContainer}>
|
<div style={styles.statsContainer}>
|
||||||
<div style={styles.statCard}>
|
<div style={styles.statCard}>
|
||||||
<span style={styles.statValue}>{heatmapData.users.length}</span>
|
<span style={styles.statValue}>{heatmapData.users.length}</span>
|
||||||
<span style={styles.statLabel}>Team Members</span>
|
<span style={styles.statLabel}>{t('team.title')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.statCard}>
|
<div style={styles.statCard}>
|
||||||
<span style={styles.statValue}>
|
<span style={styles.statValue}>
|
||||||
{heatmapData.users.filter((u) => u.load_level === 'overloaded').length}
|
{heatmapData.users.filter((u) => u.load_level === 'overloaded').length}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.statLabel}>Overloaded</span>
|
<span style={styles.statLabel}>{t('status.overloaded')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.statCard}>
|
<div style={styles.statCard}>
|
||||||
<span style={styles.statValue}>
|
<span style={styles.statValue}>
|
||||||
{heatmapData.users.filter((u) => u.load_level === 'warning').length}
|
{heatmapData.users.filter((u) => u.load_level === 'warning').length}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.statLabel}>At Risk</span>
|
<span style={styles.statLabel}>{t('team.overloaded')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.statCard}>
|
<div style={styles.statCard}>
|
||||||
<span style={styles.statValue}>
|
<span style={styles.statValue}>
|
||||||
@@ -164,7 +166,7 @@ export default function WorkloadPage() {
|
|||||||
heatmapData.users.length
|
heatmapData.users.length
|
||||||
)}%
|
)}%
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.statLabel}>Avg. Load</span>
|
<span style={styles.statLabel}>{t('metrics.utilization')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user