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",
|
||||
"axios": "^1.6.0",
|
||||
"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-dom": "^18.2.0",
|
||||
"react-i18next": "^16.5.1",
|
||||
"react-router-dom": "^6.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -340,7 +344,6 @@
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1913,6 +1916,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"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_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": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
@@ -2410,6 +2431,56 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"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_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": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
@@ -2771,6 +2884,33 @@
|
||||
"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": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
@@ -3063,8 +3203,9 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -3104,6 +3245,15 @@
|
||||
"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": {
|
||||
"version": "5.4.21",
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
||||
@@ -19,8 +19,12 @@
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"axios": "^1.6.0",
|
||||
"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-dom": "^18.2.0",
|
||||
"react-i18next": "^16.5.1",
|
||||
"react-router-dom": "^6.21.0"
|
||||
},
|
||||
"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 { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { NotificationBell } from './NotificationBell'
|
||||
import { LanguageSwitcher } from './LanguageSwitcher'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
@@ -17,11 +20,11 @@ export default function Layout({ children }: LayoutProps) {
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Dashboard' },
|
||||
{ path: '/spaces', label: 'Spaces' },
|
||||
{ path: '/workload', label: 'Workload' },
|
||||
{ path: '/project-health', label: 'Health' },
|
||||
...(user?.is_system_admin ? [{ path: '/audit', label: 'Audit' }] : []),
|
||||
{ path: '/', labelKey: 'nav.dashboard' },
|
||||
{ path: '/spaces', labelKey: 'nav.spaces' },
|
||||
{ path: '/workload', labelKey: 'nav.workload' },
|
||||
{ path: '/project-health', labelKey: 'nav.health' },
|
||||
...(user?.is_system_admin ? [{ path: '/audit', labelKey: 'nav.audit' }] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -41,19 +44,20 @@ export default function Layout({ children }: LayoutProps) {
|
||||
...(location.pathname === item.path ? styles.navItemActive : {}),
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div style={styles.headerRight}>
|
||||
<LanguageSwitcher />
|
||||
<NotificationBell />
|
||||
<span style={styles.userName}>{user?.name}</span>
|
||||
{user?.is_system_admin && (
|
||||
<span style={styles.badge}>Admin</span>
|
||||
)}
|
||||
<button onClick={handleLogout} style={styles.logoutButton}>
|
||||
Logout
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</div>
|
||||
</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 { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
@@ -6,20 +6,36 @@ import { AuthProvider } from './contexts/AuthContext'
|
||||
import { NotificationProvider } from './contexts/NotificationContext'
|
||||
import { ProjectSyncProvider } from './contexts/ProjectSyncContext'
|
||||
import { ToastProvider } from './contexts/ToastContext'
|
||||
import './i18n'
|
||||
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(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<NotificationProvider>
|
||||
<ProjectSyncProvider>
|
||||
<App />
|
||||
</ProjectSyncProvider>
|
||||
</NotificationProvider>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<NotificationProvider>
|
||||
<ProjectSyncProvider>
|
||||
<App />
|
||||
</ProjectSyncProvider>
|
||||
</NotificationProvider>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</Suspense>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { SkeletonTable } from '../components/Skeleton'
|
||||
import { auditService, AuditLog, AuditLogFilters, IntegrityCheckResponse } from '../services/audit'
|
||||
@@ -280,6 +281,7 @@ function IntegrityVerificationModal({ result, isLoading, error, onClose }: Integ
|
||||
}
|
||||
|
||||
export default function AuditPage() {
|
||||
const { t } = useTranslation('audit')
|
||||
const { user } = useAuth()
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
@@ -466,15 +468,15 @@ export default function AuditPage() {
|
||||
if (!user?.is_system_admin) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h2>Access Denied</h2>
|
||||
<p>You need administrator privileges to view audit logs.</p>
|
||||
<h2>{t('common:messages.permissionDenied')}</h2>
|
||||
<p>{t('common:messages.permissionDenied')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h2>Audit Logs</h2>
|
||||
<h2>{t('title')}</h2>
|
||||
|
||||
{/* Filters */}
|
||||
<div style={styles.filtersContainer}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { dashboardApi, DashboardResponse } from '../services/dashboard'
|
||||
import {
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
import { Skeleton } from '../components/Skeleton'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useTranslation('dashboard')
|
||||
const { user } = useAuth()
|
||||
const [data, setData] = useState<DashboardResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -22,7 +24,7 @@ export default function Dashboard() {
|
||||
const response = await dashboardApi.getDashboard()
|
||||
setData(response)
|
||||
} catch (err) {
|
||||
setError('Failed to load dashboard data. Please try again.')
|
||||
setError(t('common:messages.networkError'))
|
||||
console.error('Dashboard fetch error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -93,19 +95,19 @@ export default function Dashboard() {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.welcomeSection}>
|
||||
<h1 style={styles.welcomeTitle}>Welcome, {user?.name}!</h1>
|
||||
<h1 style={styles.welcomeTitle}>{t('welcome', { name: user?.name })}</h1>
|
||||
</div>
|
||||
|
||||
<div style={styles.errorCard}>
|
||||
<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>
|
||||
<button
|
||||
style={styles.retryButton}
|
||||
onClick={fetchDashboard}
|
||||
type="button"
|
||||
>
|
||||
Try Again
|
||||
{t('common:buttons.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,9 +119,9 @@ export default function Dashboard() {
|
||||
<div style={styles.container}>
|
||||
{/* Welcome Section */}
|
||||
<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}>
|
||||
Here is your work overview for today
|
||||
{t('sections.projectOverview')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -130,27 +132,27 @@ export default function Dashboard() {
|
||||
<StatisticsCard
|
||||
icon="✓"
|
||||
value={data.task_stats.assigned_count}
|
||||
label="My Tasks"
|
||||
label={t('stats.myTasks')}
|
||||
color="#2196f3"
|
||||
/>
|
||||
<StatisticsCard
|
||||
icon="⏰"
|
||||
value={data.task_stats.due_this_week}
|
||||
label="Due This Week"
|
||||
label={t('deadlines.thisWeek')}
|
||||
color="#ff9800"
|
||||
highlight={data.task_stats.due_this_week > 0}
|
||||
/>
|
||||
<StatisticsCard
|
||||
icon="⚠"
|
||||
value={data.task_stats.overdue_count}
|
||||
label="Overdue"
|
||||
label={t('deadlines.overdue')}
|
||||
color="#f44336"
|
||||
highlight={data.task_stats.overdue_count > 0}
|
||||
/>
|
||||
<StatisticsCard
|
||||
icon="✅"
|
||||
value={data.task_stats.completion_rate}
|
||||
label="Completion Rate"
|
||||
label={t('stats.completedTasks')}
|
||||
color="#4caf50"
|
||||
suffix="%"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState, FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { LanguageSwitcher } from '../components/LanguageSwitcher'
|
||||
|
||||
export default function Login() {
|
||||
const { t } = useTranslation('auth')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
@@ -21,11 +24,11 @@ export default function Login() {
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { status?: number } }
|
||||
if (error.response?.status === 401) {
|
||||
setError('Invalid email or password')
|
||||
setError(t('errors.invalidCredentials'))
|
||||
} else if (error.response?.status === 503) {
|
||||
setError('Authentication service temporarily unavailable')
|
||||
setError(t('errors.loginFailed'))
|
||||
} else {
|
||||
setError('An error occurred. Please try again.')
|
||||
setError(t('errors.loginFailed'))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -34,16 +37,19 @@ export default function Login() {
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.languageSwitcherWrapper}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<div style={styles.card}>
|
||||
<h1 style={styles.title}>Project Control</h1>
|
||||
<p style={styles.subtitle}>Sign in to your account</p>
|
||||
<h1 style={styles.title}>{t('welcome.title')}</h1>
|
||||
<p style={styles.subtitle}>{t('login.subtitle')}</p>
|
||||
|
||||
<form onSubmit={handleSubmit} style={styles.form}>
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
|
||||
<div style={styles.field}>
|
||||
<label htmlFor="email" style={styles.label}>
|
||||
Email
|
||||
{t('login.email')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
@@ -52,14 +58,14 @@ export default function Login() {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={styles.input}
|
||||
className="login-input"
|
||||
placeholder="your.email@company.com"
|
||||
placeholder={t('login.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={styles.field}>
|
||||
<label htmlFor="password" style={styles.label}>
|
||||
Password
|
||||
{t('login.password')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
@@ -68,7 +74,7 @@ export default function Login() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={styles.input}
|
||||
className="login-input"
|
||||
placeholder="Enter your password"
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -78,7 +84,7 @@ export default function Login() {
|
||||
style={styles.button}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
{loading ? t('login.loggingIn') : t('login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -93,6 +99,12 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#f5f5f5',
|
||||
position: 'relative',
|
||||
},
|
||||
languageSwitcherWrapper: {
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: 'white',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import api from '../services/api'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
import { SkeletonGrid } from '../components/Skeleton'
|
||||
@@ -15,6 +16,7 @@ interface Space {
|
||||
}
|
||||
|
||||
export default function Spaces() {
|
||||
const { t } = useTranslation('spaces')
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const [spaces, setSpaces] = useState<Space[]>([])
|
||||
@@ -53,7 +55,7 @@ export default function Spaces() {
|
||||
setSpaces(response.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load spaces:', err)
|
||||
showToast('Failed to load spaces', 'error')
|
||||
showToast(t('common:messages.error'), 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -68,10 +70,10 @@ export default function Spaces() {
|
||||
setShowCreateModal(false)
|
||||
setNewSpace({ name: '', description: '' })
|
||||
loadSpaces()
|
||||
showToast('Space created successfully', 'success')
|
||||
showToast(t('messages.created'), 'success')
|
||||
} catch (err) {
|
||||
console.error('Failed to create space:', err)
|
||||
showToast('Failed to create space', 'error')
|
||||
showToast(t('common:messages.error'), 'error')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
@@ -92,9 +94,9 @@ export default function Spaces() {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<h1 style={styles.title}>Spaces</h1>
|
||||
<h1 style={styles.title}>{t('title')}</h1>
|
||||
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
|
||||
+ New Space
|
||||
+ {t('createSpace')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -112,21 +114,21 @@ export default function Spaces() {
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open space: ${space.name}`}
|
||||
aria-label={`${t('title')}: ${space.name}`}
|
||||
>
|
||||
<h3 style={styles.cardTitle}>{space.name}</h3>
|
||||
<p style={styles.cardDescription}>
|
||||
{space.description || 'No description'}
|
||||
{space.description || t('common:labels.noData')}
|
||||
</p>
|
||||
<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>
|
||||
))}
|
||||
|
||||
{spaces.length === 0 && (
|
||||
<div style={styles.empty}>
|
||||
<p>No spaces yet. Create your first space to get started!</p>
|
||||
<p>{t('empty.description')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -141,25 +143,25 @@ export default function Spaces() {
|
||||
aria-labelledby="create-space-title"
|
||||
>
|
||||
<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}>
|
||||
Space name
|
||||
{t('fields.name')}
|
||||
</label>
|
||||
<input
|
||||
id="space-name"
|
||||
type="text"
|
||||
placeholder="Space name"
|
||||
placeholder={t('fields.namePlaceholder')}
|
||||
value={newSpace.name}
|
||||
onChange={(e) => setNewSpace({ ...newSpace, name: e.target.value })}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="space-description" style={styles.visuallyHidden}>
|
||||
Description
|
||||
{t('fields.description')}
|
||||
</label>
|
||||
<textarea
|
||||
id="space-description"
|
||||
placeholder="Description (optional)"
|
||||
placeholder={t('fields.descriptionPlaceholder')}
|
||||
value={newSpace.description}
|
||||
onChange={(e) => setNewSpace({ ...newSpace, description: e.target.value })}
|
||||
style={styles.textarea}
|
||||
@@ -169,14 +171,14 @@ export default function Spaces() {
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
Cancel
|
||||
{t('common:buttons.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateSpace}
|
||||
disabled={creating || !newSpace.name.trim()}
|
||||
style={styles.submitButton}
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
{creating ? t('common:labels.loading') : t('common:buttons.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import api from '../services/api'
|
||||
import { KanbanBoard } from '../components/KanbanBoard'
|
||||
import { CalendarView } from '../components/CalendarView'
|
||||
@@ -64,6 +65,7 @@ const saveColumnVisibility = (projectId: string, visibility: Record<string, bool
|
||||
}
|
||||
|
||||
export default function Tasks() {
|
||||
const { t } = useTranslation('tasks')
|
||||
const { projectId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { subscribeToProject, unsubscribeFromProject, addTaskEventListener, isConnected } = useProjectSync()
|
||||
@@ -472,14 +474,14 @@ export default function Tasks() {
|
||||
<div style={styles.container}>
|
||||
<div style={styles.breadcrumb}>
|
||||
<span onClick={() => navigate('/spaces')} style={styles.breadcrumbLink}>
|
||||
Spaces
|
||||
{t('common:nav.spaces')}
|
||||
</span>
|
||||
<span style={styles.breadcrumbSeparator}>/</span>
|
||||
<span
|
||||
onClick={() => navigate(`/spaces/${project?.space_id}`)}
|
||||
style={styles.breadcrumbLink}
|
||||
>
|
||||
Projects
|
||||
{t('common:nav.projects')}
|
||||
</span>
|
||||
<span style={styles.breadcrumbSeparator}>/</span>
|
||||
<span>{project?.title}</span>
|
||||
@@ -487,13 +489,13 @@ export default function Tasks() {
|
||||
|
||||
<div style={styles.header}>
|
||||
<div style={styles.titleContainer}>
|
||||
<h1 style={styles.title}>Tasks</h1>
|
||||
<h1 style={styles.title}>{t('title')}</h1>
|
||||
{isConnected ? (
|
||||
<span style={styles.liveIndicator} title="Real-time sync active">
|
||||
<span style={styles.liveIndicator} title={t('common:labels.active')}>
|
||||
● Live
|
||||
</span>
|
||||
) : 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
|
||||
</span>
|
||||
) : null}
|
||||
@@ -507,9 +509,9 @@ export default function Tasks() {
|
||||
...styles.viewButton,
|
||||
...(viewMode === 'list' ? styles.viewButtonActive : {}),
|
||||
}}
|
||||
aria-label="List view"
|
||||
aria-label={t('views.list')}
|
||||
>
|
||||
List
|
||||
{t('views.list')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('kanban')}
|
||||
@@ -517,9 +519,9 @@ export default function Tasks() {
|
||||
...styles.viewButton,
|
||||
...(viewMode === 'kanban' ? styles.viewButtonActive : {}),
|
||||
}}
|
||||
aria-label="Kanban view"
|
||||
aria-label={t('views.kanban')}
|
||||
>
|
||||
Kanban
|
||||
{t('views.kanban')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('calendar')}
|
||||
@@ -527,9 +529,9 @@ export default function Tasks() {
|
||||
...styles.viewButton,
|
||||
...(viewMode === 'calendar' ? styles.viewButtonActive : {}),
|
||||
}}
|
||||
aria-label="Calendar view"
|
||||
aria-label={t('views.calendar')}
|
||||
>
|
||||
Calendar
|
||||
{t('views.calendar')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('gantt')}
|
||||
@@ -537,9 +539,9 @@ export default function Tasks() {
|
||||
...styles.viewButton,
|
||||
...(viewMode === 'gantt' ? styles.viewButtonActive : {}),
|
||||
}}
|
||||
aria-label="Gantt view"
|
||||
aria-label={t('views.gantt')}
|
||||
>
|
||||
Gantt
|
||||
{t('views.gantt')}
|
||||
</button>
|
||||
</div>
|
||||
{/* Column Visibility Toggle - only show when there are custom fields and in list view */}
|
||||
@@ -548,13 +550,13 @@ export default function Tasks() {
|
||||
<button
|
||||
onClick={() => setShowColumnMenu(!showColumnMenu)}
|
||||
style={styles.columnMenuButton}
|
||||
aria-label="Toggle columns"
|
||||
aria-label={t('settings:customFields.title')}
|
||||
>
|
||||
Columns
|
||||
{t('settings:customFields.title')}
|
||||
</button>
|
||||
{showColumnMenu && (
|
||||
<div style={styles.columnMenuDropdown}>
|
||||
<div style={styles.columnMenuHeader}>Show Custom Fields</div>
|
||||
<div style={styles.columnMenuHeader}>{t('settings:customFields.title')}</div>
|
||||
{customFields.map((field) => (
|
||||
<label key={field.id} style={styles.columnMenuItem}>
|
||||
<input
|
||||
@@ -567,7 +569,7 @@ export default function Tasks() {
|
||||
</label>
|
||||
))}
|
||||
{customFields.length === 0 && (
|
||||
<div style={styles.columnMenuEmpty}>No custom fields</div>
|
||||
<div style={styles.columnMenuEmpty}>{t('common:labels.noData')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -576,12 +578,12 @@ export default function Tasks() {
|
||||
<button
|
||||
onClick={() => navigate(`/projects/${projectId}/settings`)}
|
||||
style={styles.settingsButton}
|
||||
aria-label="Project settings"
|
||||
aria-label={t('common:nav.settings')}
|
||||
>
|
||||
Settings
|
||||
{t('common:nav.settings')}
|
||||
</button>
|
||||
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
|
||||
+ New Task
|
||||
+ {t('createTask')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -651,7 +653,7 @@ export default function Tasks() {
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<div style={styles.empty}>
|
||||
<p>No tasks yet. Create your first task!</p>
|
||||
<p>{t('empty.description')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -696,51 +698,51 @@ export default function Tasks() {
|
||||
aria-labelledby="create-task-title"
|
||||
>
|
||||
<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}>
|
||||
Task title
|
||||
{t('fields.title')}
|
||||
</label>
|
||||
<input
|
||||
id="task-title"
|
||||
type="text"
|
||||
placeholder="Task title"
|
||||
placeholder={t('fields.titlePlaceholder')}
|
||||
value={newTask.title}
|
||||
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="task-description" style={styles.visuallyHidden}>
|
||||
Description
|
||||
{t('fields.description')}
|
||||
</label>
|
||||
<textarea
|
||||
id="task-description"
|
||||
placeholder="Description (optional)"
|
||||
placeholder={t('fields.descriptionPlaceholder')}
|
||||
value={newTask.description}
|
||||
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
||||
style={styles.textarea}
|
||||
/>
|
||||
|
||||
<label style={styles.label}>Priority</label>
|
||||
<label style={styles.label}>{t('fields.priority')}</label>
|
||||
<select
|
||||
value={newTask.priority}
|
||||
onChange={(e) => setNewTask({ ...newTask, priority: e.target.value })}
|
||||
style={styles.select}
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
<option value="low">{t('priority.low')}</option>
|
||||
<option value="medium">{t('priority.medium')}</option>
|
||||
<option value="high">{t('priority.high')}</option>
|
||||
<option value="urgent">{t('priority.urgent')}</option>
|
||||
</select>
|
||||
|
||||
<label style={styles.label}>Assignee</label>
|
||||
<label style={styles.label}>{t('fields.assignee')}</label>
|
||||
<UserSelect
|
||||
value={newTask.assignee_id}
|
||||
onChange={handleAssigneeChange}
|
||||
placeholder="Select assignee..."
|
||||
placeholder={t('common:labels.selectAssignee')}
|
||||
/>
|
||||
<div style={styles.fieldSpacer} />
|
||||
|
||||
<label style={styles.label}>Due Date</label>
|
||||
<label style={styles.label}>{t('fields.dueDate')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newTask.due_date}
|
||||
@@ -748,7 +750,7 @@ export default function Tasks() {
|
||||
style={styles.input}
|
||||
/>
|
||||
|
||||
<label style={styles.label}>Time Estimate (hours)</label>
|
||||
<label style={styles.label}>{t('fields.estimatedHours')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -763,7 +765,7 @@ export default function Tasks() {
|
||||
{customFields.filter((f) => f.field_type !== 'formula').length > 0 && (
|
||||
<>
|
||||
<div style={styles.customFieldsDivider} />
|
||||
<div style={styles.customFieldsTitle}>Custom Fields</div>
|
||||
<div style={styles.customFieldsTitle}>{t('settings:customFields.title')}</div>
|
||||
{customFields
|
||||
.filter((field) => field.field_type !== 'formula')
|
||||
.map((field) => (
|
||||
@@ -792,14 +794,14 @@ export default function Tasks() {
|
||||
|
||||
<div style={styles.modalActions}>
|
||||
<button onClick={() => setShowCreateModal(false)} style={styles.cancelButton}>
|
||||
Cancel
|
||||
{t('common:buttons.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateTask}
|
||||
disabled={creating || !newTask.title.trim()}
|
||||
style={styles.submitButton}
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
{creating ? t('common:labels.loading') : t('common:buttons.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { WorkloadHeatmap } from '../components/WorkloadHeatmap'
|
||||
import { WorkloadUserDetail } from '../components/WorkloadUserDetail'
|
||||
import { SkeletonTable } from '../components/Skeleton'
|
||||
@@ -29,6 +30,7 @@ function formatWeekDisplay(date: Date): string {
|
||||
}
|
||||
|
||||
export default function WorkloadPage() {
|
||||
const { t } = useTranslation('workload')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [heatmapData, setHeatmapData] = useState<WorkloadHeatmapResponse | null>(null)
|
||||
@@ -93,9 +95,9 @@ export default function WorkloadPage() {
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<div>
|
||||
<h1 style={styles.title}>Team Workload</h1>
|
||||
<h1 style={styles.title}>{t('title')}</h1>
|
||||
<p style={styles.subtitle}>
|
||||
Monitor team capacity and task distribution
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,19 +145,19 @@ export default function WorkloadPage() {
|
||||
<div style={styles.statsContainer}>
|
||||
<div style={styles.statCard}>
|
||||
<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 style={styles.statCard}>
|
||||
<span style={styles.statValue}>
|
||||
{heatmapData.users.filter((u) => u.load_level === 'overloaded').length}
|
||||
</span>
|
||||
<span style={styles.statLabel}>Overloaded</span>
|
||||
<span style={styles.statLabel}>{t('status.overloaded')}</span>
|
||||
</div>
|
||||
<div style={styles.statCard}>
|
||||
<span style={styles.statValue}>
|
||||
{heatmapData.users.filter((u) => u.load_level === 'warning').length}
|
||||
</span>
|
||||
<span style={styles.statLabel}>At Risk</span>
|
||||
<span style={styles.statLabel}>{t('team.overloaded')}</span>
|
||||
</div>
|
||||
<div style={styles.statCard}>
|
||||
<span style={styles.statValue}>
|
||||
@@ -164,7 +166,7 @@ export default function WorkloadPage() {
|
||||
heatmapData.users.length
|
||||
)}%
|
||||
</span>
|
||||
<span style={styles.statLabel}>Avg. Load</span>
|
||||
<span style={styles.statLabel}>{t('metrics.utilization')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user