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:
beabigegg
2026-01-08 23:18:41 +08:00
parent 1e31def7ba
commit 4bc3c24360
32 changed files with 1741 additions and 104 deletions

View File

@@ -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",

View File

@@ -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": {

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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": "目前沒有符合條件的稽核記錄"
}
}

View 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": "管理您的專案、任務和團隊"
}
}

View 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": "每頁顯示"
}
}

View 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": "團隊工作負載"
}
}

View 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": "專案需要更多資料才能顯示健康度指標"
}
}

View 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": "建立您的第一個專案來開始管理任務"
}
}

View 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": "角色已變更"
}
}

View 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": "建立您的第一個工作空間來組織專案"
}
}

View 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": "沒有符合篩選條件的任務"
}
}

View 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": "目前沒有足夠的資料來顯示工作負載"
}
}

View 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

View File

@@ -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>

View 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

View File

@@ -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>,
)

View File

@@ -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}>

View File

@@ -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="%"
/>

View File

@@ -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',

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)}