feat: complete i18n translation for remaining components
- Translate pages: Projects, ProjectHealthPage, ProjectSettings - Translate components: TaskDetailModal, KanbanBoard, Comments, SubtaskList, CalendarView, BlockerDialog - Add translation keys for tasks namespace: kanban, calendar, subtasks.error, comments.error, blockers (full translation) - Add common.labels.task translation key - Fix task creation: use original_estimate instead of time_estimate Translation coverage: - 10 locale files updated (zh-TW & en) - 6 page/component files translated - ~100 new translation keys added 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -52,7 +52,8 @@
|
|||||||
"selectAssignee": "Select assignee...",
|
"selectAssignee": "Select assignee...",
|
||||||
"searchUsers": "Search users...",
|
"searchUsers": "Search users...",
|
||||||
"noUsersFound": "No users found",
|
"noUsersFound": "No users found",
|
||||||
"typeToSearch": "Type to search users"
|
"typeToSearch": "Type to search users",
|
||||||
|
"task": "Task"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"success": "Operation successful",
|
"success": "Operation successful",
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
{
|
{
|
||||||
"title": "Project Health",
|
"title": "Project Health Dashboard",
|
||||||
"subtitle": "Monitor the overall health of projects",
|
"subtitle": "Monitor project health status and risk levels across all projects",
|
||||||
"overall": {
|
"overall": {
|
||||||
"title": "Overall Health",
|
"title": "Overall Health",
|
||||||
"healthy": "Healthy",
|
"healthy": "Healthy",
|
||||||
"atRisk": "At Risk",
|
"atRisk": "At Risk",
|
||||||
"critical": "Critical"
|
"critical": "Critical"
|
||||||
},
|
},
|
||||||
|
"summary": {
|
||||||
|
"totalProjects": "Total Projects",
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"atRisk": "At Risk",
|
||||||
|
"critical": "Critical",
|
||||||
|
"avgHealth": "Avg. Health",
|
||||||
|
"withBlockers": "With Blockers",
|
||||||
|
"delayed": "Delayed"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"label": "Sort by",
|
||||||
|
"riskHigh": "Risk: High to Low",
|
||||||
|
"riskLow": "Risk: Low to High",
|
||||||
|
"healthHigh": "Health: High to Low",
|
||||||
|
"healthLow": "Health: Low to High",
|
||||||
|
"name": "Name: A to Z"
|
||||||
|
},
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"schedule": "Schedule",
|
"schedule": "Schedule",
|
||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
@@ -34,15 +51,21 @@
|
|||||||
"high": "High Risk",
|
"high": "High Risk",
|
||||||
"medium": "Medium Risk",
|
"medium": "Medium Risk",
|
||||||
"low": "Low Risk",
|
"low": "Low Risk",
|
||||||
|
"critical": "Critical",
|
||||||
"mitigated": "Mitigated"
|
"mitigated": "Mitigated"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"viewDetails": "View Details",
|
"viewDetails": "View Details",
|
||||||
"exportReport": "Export Report",
|
"exportReport": "Export Report",
|
||||||
"setAlert": "Set Alert"
|
"setAlert": "Set Alert",
|
||||||
|
"retry": "Retry"
|
||||||
},
|
},
|
||||||
|
"projectCount": "{{count}} project(s)",
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "No Health Data",
|
"title": "No projects found",
|
||||||
"description": "The project needs more data to display health metrics"
|
"description": "Create a project to start tracking health status"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "Failed to load project health data. Please try again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,26 @@
|
|||||||
"editProject": "Edit Project",
|
"editProject": "Edit Project",
|
||||||
"deleteProject": "Delete Project",
|
"deleteProject": "Delete Project",
|
||||||
"projectSettings": "Project Settings",
|
"projectSettings": "Project Settings",
|
||||||
|
"newProject": "New Project",
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"namePlaceholder": "Enter project name",
|
"namePlaceholder": "Enter project name",
|
||||||
|
"title": "Project Title",
|
||||||
|
"titlePlaceholder": "Enter project title",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"descriptionPlaceholder": "Enter project description",
|
"descriptionPlaceholder": "Description (optional)",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"startDate": "Start Date",
|
"startDate": "Start Date",
|
||||||
"endDate": "End Date",
|
"endDate": "End Date",
|
||||||
"owner": "Project Owner",
|
"owner": "Project Owner",
|
||||||
"space": "Space"
|
"space": "Space",
|
||||||
|
"securityLevel": "Security Level"
|
||||||
|
},
|
||||||
|
"securityLevel": {
|
||||||
|
"label": "Security Level",
|
||||||
|
"public": "Public - All users",
|
||||||
|
"department": "Department - Same department only",
|
||||||
|
"confidential": "Confidential - Owner only"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"planning": "Planning",
|
"planning": "Planning",
|
||||||
@@ -37,10 +47,18 @@
|
|||||||
"overdue": "Overdue",
|
"overdue": "Overdue",
|
||||||
"progress": "Overall Progress"
|
"progress": "Overall Progress"
|
||||||
},
|
},
|
||||||
|
"card": {
|
||||||
|
"tasks": "{{count}} tasks",
|
||||||
|
"owner": "Owner",
|
||||||
|
"noDescription": "No description",
|
||||||
|
"unknown": "Unknown"
|
||||||
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"created": "Project created",
|
"created": "Project created successfully",
|
||||||
"updated": "Project updated",
|
"updated": "Project updated",
|
||||||
"deleted": "Project deleted",
|
"deleted": "Project deleted",
|
||||||
|
"loadFailed": "Failed to load projects",
|
||||||
|
"createFailed": "Failed to create project",
|
||||||
"confirmDelete": "Are you sure you want to delete this project? This will delete all related tasks."
|
"confirmDelete": "Are you sure you want to delete this project? This will delete all related tasks."
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"projectSettings": "Project Settings",
|
"projectSettings": "Project Settings",
|
||||||
|
"backToTasks": "Back to Tasks",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"members": "Members",
|
"members": "Members",
|
||||||
@@ -13,10 +14,13 @@
|
|||||||
"title": "General Settings",
|
"title": "General Settings",
|
||||||
"projectName": "Project Name",
|
"projectName": "Project Name",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
|
"noDescription": "No description",
|
||||||
|
"securityLevel": "Security Level",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"visibility": "Visibility",
|
"visibility": "Visibility",
|
||||||
"public": "Public",
|
"public": "Public",
|
||||||
"private": "Private"
|
"private": "Private",
|
||||||
|
"helpText": "To edit project details, contact the project owner."
|
||||||
},
|
},
|
||||||
"members": {
|
"members": {
|
||||||
"title": "Member Management",
|
"title": "Member Management",
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"attachments": "Attachments",
|
"attachments": "Attachments",
|
||||||
"comments": "Comments",
|
"comments": "Comments",
|
||||||
"watchers": "Watchers",
|
"watchers": "Watchers",
|
||||||
"blockers": "Blockers"
|
"blockers": "Blockers",
|
||||||
|
"hours": "{{count}} hours"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"todo": "To Do",
|
"todo": "To Do",
|
||||||
@@ -31,7 +32,11 @@
|
|||||||
"review": "In Review",
|
"review": "In Review",
|
||||||
"done": "Done",
|
"done": "Done",
|
||||||
"cancelled": "Cancelled",
|
"cancelled": "Cancelled",
|
||||||
"blocked": "Blocked"
|
"blocked": "Blocked",
|
||||||
|
"noStatus": "No Status",
|
||||||
|
"unassigned": "Unassigned",
|
||||||
|
"noDueDate": "No due date",
|
||||||
|
"notEstimated": "Not estimated"
|
||||||
},
|
},
|
||||||
"priority": {
|
"priority": {
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
@@ -86,17 +91,32 @@
|
|||||||
"add": "Add Subtask",
|
"add": "Add Subtask",
|
||||||
"placeholder": "Enter subtask title",
|
"placeholder": "Enter subtask title",
|
||||||
"completed": "{{count}} / {{total}} completed",
|
"completed": "{{count}} / {{total}} completed",
|
||||||
"empty": "No subtasks"
|
"count": "{{count}} subtask(s)",
|
||||||
|
"empty": "No subtasks",
|
||||||
|
"adding": "Adding...",
|
||||||
|
"error": {
|
||||||
|
"load": "Failed to load subtasks",
|
||||||
|
"create": "Failed to create subtask"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"comments": {
|
"comments": {
|
||||||
"title": "Comments",
|
"title": "Comments",
|
||||||
"add": "Add Comment",
|
"add": "Post Comment",
|
||||||
"placeholder": "Write your comment...",
|
"placeholder": "Add a comment... Use @name to mention someone",
|
||||||
"edited": "edited",
|
"edited": "edited",
|
||||||
"delete": "Delete Comment",
|
"delete": "Delete Comment",
|
||||||
"confirmDelete": "Are you sure you want to delete this comment?",
|
"confirmDelete": "Are you sure you want to delete this comment? This action cannot be undone.",
|
||||||
"empty": "No comments yet",
|
"empty": "No comments yet",
|
||||||
"reply": "Reply"
|
"reply": "Reply",
|
||||||
|
"mentioned": "Mentioned",
|
||||||
|
"replyingTo": "Replying to comment",
|
||||||
|
"posting": "Posting...",
|
||||||
|
"error": {
|
||||||
|
"load": "Failed to load comments",
|
||||||
|
"post": "Failed to post comment",
|
||||||
|
"update": "Failed to update comment",
|
||||||
|
"delete": "Failed to delete comment"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"attachments": {
|
"attachments": {
|
||||||
"title": "Attachments",
|
"title": "Attachments",
|
||||||
@@ -113,7 +133,26 @@
|
|||||||
"blockedBy": "Blocked by",
|
"blockedBy": "Blocked by",
|
||||||
"blocking": "Blocking",
|
"blocking": "Blocking",
|
||||||
"remove": "Remove blocker",
|
"remove": "Remove blocker",
|
||||||
"empty": "No blockers"
|
"empty": "No blockers",
|
||||||
|
"taskBlocked": "Task Blocked",
|
||||||
|
"markAsBlocked": "Mark as Blocked",
|
||||||
|
"activeBlocker": "Active Blocker",
|
||||||
|
"reportedBy": "Reported by",
|
||||||
|
"on": "on",
|
||||||
|
"resolutionNote": "Resolution Note",
|
||||||
|
"resolutionPlaceholder": "Describe how the blocker was resolved...",
|
||||||
|
"resolving": "Resolving...",
|
||||||
|
"resolveBlocker": "Resolve Blocker",
|
||||||
|
"blockerReason": "Blocker Reason",
|
||||||
|
"reasonPlaceholder": "Describe what is blocking this task...",
|
||||||
|
"history": "Blocker History",
|
||||||
|
"reported": "Reported",
|
||||||
|
"resolved": "Resolved",
|
||||||
|
"error": {
|
||||||
|
"load": "Failed to load blockers",
|
||||||
|
"create": "Failed to create blocker",
|
||||||
|
"resolve": "Failed to resolve blocker"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"created": "Task created",
|
"created": "Task created",
|
||||||
@@ -126,6 +165,18 @@
|
|||||||
"attachmentUploaded": "Attachment uploaded",
|
"attachmentUploaded": "Attachment uploaded",
|
||||||
"confirmDelete": "Are you sure you want to delete this task? This action cannot be undone."
|
"confirmDelete": "Are you sure you want to delete this task? This action cannot be undone."
|
||||||
},
|
},
|
||||||
|
"kanban": {
|
||||||
|
"dropHere": "Drop tasks here"
|
||||||
|
},
|
||||||
|
"calendar": {
|
||||||
|
"allAssignees": "All Assignees",
|
||||||
|
"allPriorities": "All Priorities",
|
||||||
|
"activeOnly": "Active Only",
|
||||||
|
"completedOnly": "Completed Only",
|
||||||
|
"clearFilters": "Clear Filters",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"completed": "Completed"
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "No Tasks",
|
"title": "No Tasks",
|
||||||
"description": "There are no tasks yet. Create your first task to get started!",
|
"description": "There are no tasks yet. Create your first task to get started!",
|
||||||
|
|||||||
@@ -52,7 +52,8 @@
|
|||||||
"selectAssignee": "選擇負責人...",
|
"selectAssignee": "選擇負責人...",
|
||||||
"searchUsers": "搜尋使用者...",
|
"searchUsers": "搜尋使用者...",
|
||||||
"noUsersFound": "找不到使用者",
|
"noUsersFound": "找不到使用者",
|
||||||
"typeToSearch": "輸入以搜尋使用者"
|
"typeToSearch": "輸入以搜尋使用者",
|
||||||
|
"task": "任務"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"success": "操作成功",
|
"success": "操作成功",
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
{
|
{
|
||||||
"title": "專案健康度",
|
"title": "專案健康度儀表板",
|
||||||
"subtitle": "監控專案的整體健康狀況",
|
"subtitle": "監控所有專案的健康狀況與風險等級",
|
||||||
"overall": {
|
"overall": {
|
||||||
"title": "整體健康度",
|
"title": "整體健康度",
|
||||||
"healthy": "健康",
|
"healthy": "健康",
|
||||||
"atRisk": "風險中",
|
"atRisk": "風險中",
|
||||||
"critical": "危急"
|
"critical": "危急"
|
||||||
},
|
},
|
||||||
|
"summary": {
|
||||||
|
"totalProjects": "專案總數",
|
||||||
|
"healthy": "健康",
|
||||||
|
"atRisk": "風險中",
|
||||||
|
"critical": "危急",
|
||||||
|
"avgHealth": "平均健康度",
|
||||||
|
"withBlockers": "有阻擋問題",
|
||||||
|
"delayed": "延遲"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"label": "排序方式",
|
||||||
|
"riskHigh": "風險:高到低",
|
||||||
|
"riskLow": "風險:低到高",
|
||||||
|
"healthHigh": "健康度:高到低",
|
||||||
|
"healthLow": "健康度:低到高",
|
||||||
|
"name": "名稱:A 到 Z"
|
||||||
|
},
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"schedule": "進度",
|
"schedule": "進度",
|
||||||
"budget": "預算",
|
"budget": "預算",
|
||||||
@@ -34,15 +51,21 @@
|
|||||||
"high": "高風險",
|
"high": "高風險",
|
||||||
"medium": "中風險",
|
"medium": "中風險",
|
||||||
"low": "低風險",
|
"low": "低風險",
|
||||||
|
"critical": "危急",
|
||||||
"mitigated": "已緩解"
|
"mitigated": "已緩解"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"viewDetails": "查看詳情",
|
"viewDetails": "查看詳情",
|
||||||
"exportReport": "匯出報告",
|
"exportReport": "匯出報告",
|
||||||
"setAlert": "設定警示"
|
"setAlert": "設定警示",
|
||||||
|
"retry": "重試"
|
||||||
},
|
},
|
||||||
|
"projectCount": "{{count}} 個專案",
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "沒有健康度資料",
|
"title": "沒有專案",
|
||||||
"description": "專案需要更多資料才能顯示健康度指標"
|
"description": "建立專案以開始追蹤健康狀態"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "載入專案健康度資料失敗,請重試。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,26 @@
|
|||||||
"editProject": "編輯專案",
|
"editProject": "編輯專案",
|
||||||
"deleteProject": "刪除專案",
|
"deleteProject": "刪除專案",
|
||||||
"projectSettings": "專案設定",
|
"projectSettings": "專案設定",
|
||||||
|
"newProject": "新增專案",
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "名稱",
|
"name": "名稱",
|
||||||
"namePlaceholder": "輸入專案名稱",
|
"namePlaceholder": "輸入專案名稱",
|
||||||
|
"title": "專案標題",
|
||||||
|
"titlePlaceholder": "輸入專案標題",
|
||||||
"description": "描述",
|
"description": "描述",
|
||||||
"descriptionPlaceholder": "輸入專案描述",
|
"descriptionPlaceholder": "輸入專案描述(選填)",
|
||||||
"status": "狀態",
|
"status": "狀態",
|
||||||
"startDate": "開始日期",
|
"startDate": "開始日期",
|
||||||
"endDate": "結束日期",
|
"endDate": "結束日期",
|
||||||
"owner": "專案負責人",
|
"owner": "專案負責人",
|
||||||
"space": "工作空間"
|
"space": "工作空間",
|
||||||
|
"securityLevel": "安全等級"
|
||||||
|
},
|
||||||
|
"securityLevel": {
|
||||||
|
"label": "安全等級",
|
||||||
|
"public": "公開 - 所有使用者",
|
||||||
|
"department": "部門 - 僅同部門",
|
||||||
|
"confidential": "機密 - 僅負責人"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"planning": "規劃中",
|
"planning": "規劃中",
|
||||||
@@ -37,10 +47,18 @@
|
|||||||
"overdue": "逾期",
|
"overdue": "逾期",
|
||||||
"progress": "整體進度"
|
"progress": "整體進度"
|
||||||
},
|
},
|
||||||
|
"card": {
|
||||||
|
"tasks": "{{count}} 個任務",
|
||||||
|
"owner": "負責人",
|
||||||
|
"noDescription": "沒有描述",
|
||||||
|
"unknown": "未知"
|
||||||
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"created": "專案已建立",
|
"created": "專案已建立",
|
||||||
"updated": "專案已更新",
|
"updated": "專案已更新",
|
||||||
"deleted": "專案已刪除",
|
"deleted": "專案已刪除",
|
||||||
|
"loadFailed": "載入專案失敗",
|
||||||
|
"createFailed": "建立專案失敗",
|
||||||
"confirmDelete": "確定要刪除此專案嗎?此操作將刪除所有相關任務。"
|
"confirmDelete": "確定要刪除此專案嗎?此操作將刪除所有相關任務。"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "設定",
|
"title": "設定",
|
||||||
"projectSettings": "專案設定",
|
"projectSettings": "專案設定",
|
||||||
|
"backToTasks": "返回任務",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"general": "一般",
|
"general": "一般",
|
||||||
"members": "成員",
|
"members": "成員",
|
||||||
@@ -13,10 +14,13 @@
|
|||||||
"title": "一般設定",
|
"title": "一般設定",
|
||||||
"projectName": "專案名稱",
|
"projectName": "專案名稱",
|
||||||
"description": "描述",
|
"description": "描述",
|
||||||
|
"noDescription": "沒有描述",
|
||||||
|
"securityLevel": "安全等級",
|
||||||
"status": "狀態",
|
"status": "狀態",
|
||||||
"visibility": "可見性",
|
"visibility": "可見性",
|
||||||
"public": "公開",
|
"public": "公開",
|
||||||
"private": "私人"
|
"private": "私人",
|
||||||
|
"helpText": "如需編輯專案詳情,請聯繫專案負責人。"
|
||||||
},
|
},
|
||||||
"members": {
|
"members": {
|
||||||
"title": "成員管理",
|
"title": "成員管理",
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"attachments": "附件",
|
"attachments": "附件",
|
||||||
"comments": "留言",
|
"comments": "留言",
|
||||||
"watchers": "關注者",
|
"watchers": "關注者",
|
||||||
"blockers": "阻擋項目"
|
"blockers": "阻擋項目",
|
||||||
|
"hours": "{{count}} 小時"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"todo": "待處理",
|
"todo": "待處理",
|
||||||
@@ -31,7 +32,11 @@
|
|||||||
"review": "審核中",
|
"review": "審核中",
|
||||||
"done": "已完成",
|
"done": "已完成",
|
||||||
"cancelled": "已取消",
|
"cancelled": "已取消",
|
||||||
"blocked": "被阻擋"
|
"blocked": "被阻擋",
|
||||||
|
"noStatus": "無狀態",
|
||||||
|
"unassigned": "未指派",
|
||||||
|
"noDueDate": "無截止日期",
|
||||||
|
"notEstimated": "未預估"
|
||||||
},
|
},
|
||||||
"priority": {
|
"priority": {
|
||||||
"low": "低",
|
"low": "低",
|
||||||
@@ -86,17 +91,32 @@
|
|||||||
"add": "新增子任務",
|
"add": "新增子任務",
|
||||||
"placeholder": "輸入子任務標題",
|
"placeholder": "輸入子任務標題",
|
||||||
"completed": "已完成 {{count}} / {{total}}",
|
"completed": "已完成 {{count}} / {{total}}",
|
||||||
"empty": "沒有子任務"
|
"count": "{{count}} 個子任務",
|
||||||
|
"empty": "沒有子任務",
|
||||||
|
"adding": "新增中...",
|
||||||
|
"error": {
|
||||||
|
"load": "載入子任務失敗",
|
||||||
|
"create": "建立子任務失敗"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"comments": {
|
"comments": {
|
||||||
"title": "留言",
|
"title": "留言",
|
||||||
"add": "新增留言",
|
"add": "發表留言",
|
||||||
"placeholder": "輸入您的留言...",
|
"placeholder": "新增留言... 使用 @name 來提及某人",
|
||||||
"edited": "已編輯",
|
"edited": "已編輯",
|
||||||
"delete": "刪除留言",
|
"delete": "刪除留言",
|
||||||
"confirmDelete": "確定要刪除此留言嗎?",
|
"confirmDelete": "確定要刪除此留言嗎?此操作無法復原。",
|
||||||
"empty": "還沒有留言",
|
"empty": "還沒有留言",
|
||||||
"reply": "回覆"
|
"reply": "回覆",
|
||||||
|
"mentioned": "提及",
|
||||||
|
"replyingTo": "回覆留言中",
|
||||||
|
"posting": "發送中...",
|
||||||
|
"error": {
|
||||||
|
"load": "載入留言失敗",
|
||||||
|
"post": "發表留言失敗",
|
||||||
|
"update": "更新留言失敗",
|
||||||
|
"delete": "刪除留言失敗"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"attachments": {
|
"attachments": {
|
||||||
"title": "附件",
|
"title": "附件",
|
||||||
@@ -113,7 +133,26 @@
|
|||||||
"blockedBy": "被以下任務阻擋",
|
"blockedBy": "被以下任務阻擋",
|
||||||
"blocking": "正在阻擋以下任務",
|
"blocking": "正在阻擋以下任務",
|
||||||
"remove": "移除阻擋關係",
|
"remove": "移除阻擋關係",
|
||||||
"empty": "沒有阻擋項目"
|
"empty": "沒有阻擋項目",
|
||||||
|
"taskBlocked": "任務被阻擋",
|
||||||
|
"markAsBlocked": "標記為阻擋",
|
||||||
|
"activeBlocker": "進行中的阻擋",
|
||||||
|
"reportedBy": "回報者",
|
||||||
|
"on": "於",
|
||||||
|
"resolutionNote": "解決說明",
|
||||||
|
"resolutionPlaceholder": "描述如何解決阻擋問題...",
|
||||||
|
"resolving": "解決中...",
|
||||||
|
"resolveBlocker": "解決阻擋",
|
||||||
|
"blockerReason": "阻擋原因",
|
||||||
|
"reasonPlaceholder": "描述阻擋此任務的原因...",
|
||||||
|
"history": "阻擋歷史",
|
||||||
|
"reported": "回報",
|
||||||
|
"resolved": "已解決",
|
||||||
|
"error": {
|
||||||
|
"load": "載入阻擋項目失敗",
|
||||||
|
"create": "建立阻擋項目失敗",
|
||||||
|
"resolve": "解決阻擋項目失敗"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"created": "任務已建立",
|
"created": "任務已建立",
|
||||||
@@ -126,6 +165,18 @@
|
|||||||
"attachmentUploaded": "附件已上傳",
|
"attachmentUploaded": "附件已上傳",
|
||||||
"confirmDelete": "確定要刪除此任務嗎?此操作無法復原。"
|
"confirmDelete": "確定要刪除此任務嗎?此操作無法復原。"
|
||||||
},
|
},
|
||||||
|
"kanban": {
|
||||||
|
"dropHere": "將任務拖放至此"
|
||||||
|
},
|
||||||
|
"calendar": {
|
||||||
|
"allAssignees": "所有負責人",
|
||||||
|
"allPriorities": "所有優先順序",
|
||||||
|
"activeOnly": "僅進行中",
|
||||||
|
"completedOnly": "僅已完成",
|
||||||
|
"clearFilters": "清除篩選",
|
||||||
|
"overdue": "逾期",
|
||||||
|
"completed": "已完成"
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "沒有任務",
|
"title": "沒有任務",
|
||||||
"description": "目前沒有任務。建立您的第一個任務開始吧!",
|
"description": "目前沒有任務。建立您的第一個任務開始吧!",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { blockersApi, Blocker } from '../services/collaboration'
|
import { blockersApi, Blocker } from '../services/collaboration'
|
||||||
import { SkeletonList } from './Skeleton'
|
import { SkeletonList } from './Skeleton'
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export function BlockerDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
onBlockerChange,
|
onBlockerChange,
|
||||||
}: BlockerDialogProps) {
|
}: BlockerDialogProps) {
|
||||||
|
const { t } = useTranslation('tasks')
|
||||||
const [blockers, setBlockers] = useState<Blocker[]>([])
|
const [blockers, setBlockers] = useState<Blocker[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [reason, setReason] = useState('')
|
const [reason, setReason] = useState('')
|
||||||
@@ -34,7 +36,7 @@ export function BlockerDialog({
|
|||||||
const active = response.blockers.find(b => !b.resolved_at)
|
const active = response.blockers.find(b => !b.resolved_at)
|
||||||
setActiveBlocker(active || null)
|
setActiveBlocker(active || null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to load blockers')
|
setError(t('blockers.error.load'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -55,7 +57,7 @@ export function BlockerDialog({
|
|||||||
setError(null)
|
setError(null)
|
||||||
onBlockerChange()
|
onBlockerChange()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to create blocker')
|
setError(t('blockers.error.create'))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -74,7 +76,7 @@ export function BlockerDialog({
|
|||||||
setError(null)
|
setError(null)
|
||||||
onBlockerChange()
|
onBlockerChange()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to resolve blocker')
|
setError(t('blockers.error.resolve'))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -85,7 +87,7 @@ export function BlockerDialog({
|
|||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden">
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden">
|
||||||
<div className="p-4 border-b flex justify-between items-center">
|
<div className="p-4 border-b flex justify-between items-center">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">
|
||||||
{isBlocked ? 'Task Blocked' : 'Mark as Blocked'}
|
{isBlocked ? t('blockers.taskBlocked') : t('blockers.markAsBlocked')}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -96,7 +98,7 @@ export function BlockerDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 overflow-y-auto max-h-[60vh]">
|
<div className="p-4 overflow-y-auto max-h-[60vh]">
|
||||||
<p className="text-gray-600 mb-4">Task: {taskTitle}</p>
|
<p className="text-gray-600 mb-4">{t('common:labels.task')}: {taskTitle}</p>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-2 bg-red-100 text-red-700 rounded text-sm mb-4">
|
<div className="p-2 bg-red-100 text-red-700 rounded text-sm mb-4">
|
||||||
@@ -114,23 +116,23 @@ export function BlockerDialog({
|
|||||||
{activeBlocker && (
|
{activeBlocker && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||||
<h3 className="font-semibold text-red-800 mb-2">
|
<h3 className="font-semibold text-red-800 mb-2">
|
||||||
Active Blocker
|
{t('blockers.activeBlocker')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-700 mb-2">{activeBlocker.reason}</p>
|
<p className="text-gray-700 mb-2">{activeBlocker.reason}</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Reported by {activeBlocker.reporter.name} on{' '}
|
{t('blockers.reportedBy')} {activeBlocker.reporter.name} {t('blockers.on')}{' '}
|
||||||
{new Date(activeBlocker.created_at).toLocaleString()}
|
{new Date(activeBlocker.created_at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Resolve form */}
|
{/* Resolve form */}
|
||||||
<form onSubmit={handleResolveBlocker} className="mt-4">
|
<form onSubmit={handleResolveBlocker} className="mt-4">
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
Resolution Note
|
{t('blockers.resolutionNote')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={resolutionNote}
|
value={resolutionNote}
|
||||||
onChange={e => setResolutionNote(e.target.value)}
|
onChange={e => setResolutionNote(e.target.value)}
|
||||||
placeholder="Describe how the blocker was resolved..."
|
placeholder={t('blockers.resolutionPlaceholder')}
|
||||||
className="w-full p-2 border rounded resize-none"
|
className="w-full p-2 border rounded resize-none"
|
||||||
rows={3}
|
rows={3}
|
||||||
required
|
required
|
||||||
@@ -140,7 +142,7 @@ export function BlockerDialog({
|
|||||||
disabled={submitting || !resolutionNote.trim()}
|
disabled={submitting || !resolutionNote.trim()}
|
||||||
className="mt-2 px-4 py-2 bg-green-600 text-white rounded disabled:opacity-50"
|
className="mt-2 px-4 py-2 bg-green-600 text-white rounded disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? 'Resolving...' : 'Resolve Blocker'}
|
{submitting ? t('blockers.resolving') : t('blockers.resolveBlocker')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,12 +152,12 @@ export function BlockerDialog({
|
|||||||
{!activeBlocker && (
|
{!activeBlocker && (
|
||||||
<form onSubmit={handleCreateBlocker} className="mb-4">
|
<form onSubmit={handleCreateBlocker} className="mb-4">
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
Blocker Reason
|
{t('blockers.blockerReason')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={reason}
|
value={reason}
|
||||||
onChange={e => setReason(e.target.value)}
|
onChange={e => setReason(e.target.value)}
|
||||||
placeholder="Describe what is blocking this task..."
|
placeholder={t('blockers.reasonPlaceholder')}
|
||||||
className="w-full p-2 border rounded resize-none"
|
className="w-full p-2 border rounded resize-none"
|
||||||
rows={3}
|
rows={3}
|
||||||
required
|
required
|
||||||
@@ -165,7 +167,7 @@ export function BlockerDialog({
|
|||||||
disabled={submitting || !reason.trim()}
|
disabled={submitting || !reason.trim()}
|
||||||
className="mt-2 px-4 py-2 bg-red-600 text-white rounded disabled:opacity-50"
|
className="mt-2 px-4 py-2 bg-red-600 text-white rounded disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? 'Creating...' : 'Mark as Blocked'}
|
{submitting ? t('common:labels.loading') : t('blockers.markAsBlocked')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -173,7 +175,7 @@ export function BlockerDialog({
|
|||||||
{/* History */}
|
{/* History */}
|
||||||
{blockers.filter(b => b.resolved_at).length > 0 && (
|
{blockers.filter(b => b.resolved_at).length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<h3 className="font-semibold mb-2">Blocker History</h3>
|
<h3 className="font-semibold mb-2">{t('blockers.history')}</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{blockers
|
{blockers
|
||||||
.filter(b => b.resolved_at)
|
.filter(b => b.resolved_at)
|
||||||
@@ -184,12 +186,12 @@ export function BlockerDialog({
|
|||||||
>
|
>
|
||||||
<p className="font-medium">{blocker.reason}</p>
|
<p className="font-medium">{blocker.reason}</p>
|
||||||
<p className="text-gray-500 mt-1">
|
<p className="text-gray-500 mt-1">
|
||||||
Reported: {blocker.reporter.name} •{' '}
|
{t('blockers.reported')}: {blocker.reporter.name} •{' '}
|
||||||
{new Date(blocker.created_at).toLocaleDateString()}
|
{new Date(blocker.created_at).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
{blocker.resolver && (
|
{blocker.resolver && (
|
||||||
<p className="text-green-600 mt-1">
|
<p className="text-green-600 mt-1">
|
||||||
Resolved: {blocker.resolver.name} •{' '}
|
{t('blockers.resolved')}: {blocker.resolver.name} •{' '}
|
||||||
{new Date(blocker.resolved_at!).toLocaleDateString()}
|
{new Date(blocker.resolved_at!).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -212,7 +214,7 @@ export function BlockerDialog({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
|
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
|
||||||
>
|
>
|
||||||
Close
|
{t('common:buttons.close')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import FullCalendar from '@fullcalendar/react'
|
import FullCalendar from '@fullcalendar/react'
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||||
@@ -75,6 +76,7 @@ export function CalendarView({
|
|||||||
onTaskClick,
|
onTaskClick,
|
||||||
onTaskUpdate,
|
onTaskUpdate,
|
||||||
}: CalendarViewProps) {
|
}: CalendarViewProps) {
|
||||||
|
const { t } = useTranslation('tasks')
|
||||||
const calendarRef = useRef<FullCalendar>(null)
|
const calendarRef = useRef<FullCalendar>(null)
|
||||||
const [events, setEvents] = useState<CalendarEvent[]>([])
|
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -306,13 +308,13 @@ export function CalendarView({
|
|||||||
{/* Filter Controls */}
|
{/* Filter Controls */}
|
||||||
<div style={styles.filterBar}>
|
<div style={styles.filterBar}>
|
||||||
<div style={styles.filterGroup}>
|
<div style={styles.filterGroup}>
|
||||||
<label style={styles.filterLabel}>Assignee</label>
|
<label style={styles.filterLabel}>{t('fields.assignee')}</label>
|
||||||
<select
|
<select
|
||||||
value={filterAssignee}
|
value={filterAssignee}
|
||||||
onChange={(e) => setFilterAssignee(e.target.value)}
|
onChange={(e) => setFilterAssignee(e.target.value)}
|
||||||
style={styles.filterSelect}
|
style={styles.filterSelect}
|
||||||
>
|
>
|
||||||
<option value="">All Assignees</option>
|
<option value="">{t('calendar.allAssignees')}</option>
|
||||||
{assignees.map((assignee) => (
|
{assignees.map((assignee) => (
|
||||||
<option key={assignee.id} value={assignee.id}>
|
<option key={assignee.id} value={assignee.id}>
|
||||||
{assignee.name}
|
{assignee.name}
|
||||||
@@ -322,36 +324,36 @@ export function CalendarView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.filterGroup}>
|
<div style={styles.filterGroup}>
|
||||||
<label style={styles.filterLabel}>Status</label>
|
<label style={styles.filterLabel}>{t('fields.status')}</label>
|
||||||
<select
|
<select
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={(e) => setFilterStatus(e.target.value)}
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
style={styles.filterSelect}
|
style={styles.filterSelect}
|
||||||
>
|
>
|
||||||
<option value="all">All Tasks</option>
|
<option value="all">{t('filters.all')}</option>
|
||||||
<option value="active">Active Only</option>
|
<option value="active">{t('calendar.activeOnly')}</option>
|
||||||
<option value="completed">Completed Only</option>
|
<option value="completed">{t('calendar.completedOnly')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.filterGroup}>
|
<div style={styles.filterGroup}>
|
||||||
<label style={styles.filterLabel}>Priority</label>
|
<label style={styles.filterLabel}>{t('fields.priority')}</label>
|
||||||
<select
|
<select
|
||||||
value={filterPriority}
|
value={filterPriority}
|
||||||
onChange={(e) => setFilterPriority(e.target.value)}
|
onChange={(e) => setFilterPriority(e.target.value)}
|
||||||
style={styles.filterSelect}
|
style={styles.filterSelect}
|
||||||
>
|
>
|
||||||
<option value="">All Priorities</option>
|
<option value="">{t('calendar.allPriorities')}</option>
|
||||||
<option value="urgent">Urgent</option>
|
<option value="urgent">{t('priority.urgent')}</option>
|
||||||
<option value="high">High</option>
|
<option value="high">{t('priority.high')}</option>
|
||||||
<option value="medium">Medium</option>
|
<option value="medium">{t('priority.medium')}</option>
|
||||||
<option value="low">Low</option>
|
<option value="low">{t('priority.low')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<button onClick={handleClearFilters} style={styles.clearFiltersButton}>
|
<button onClick={handleClearFilters} style={styles.clearFiltersButton}>
|
||||||
Clear Filters
|
{t('calendar.clearFilters')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -392,17 +394,17 @@ export function CalendarView({
|
|||||||
<div style={styles.legend}>
|
<div style={styles.legend}>
|
||||||
<div style={styles.legendItem}>
|
<div style={styles.legendItem}>
|
||||||
<span style={{ ...styles.legendDot, backgroundColor: '#ffebee', border: '2px solid #f44336' }} />
|
<span style={{ ...styles.legendDot, backgroundColor: '#ffebee', border: '2px solid #f44336' }} />
|
||||||
<span style={styles.legendText}>Overdue</span>
|
<span style={styles.legendText}>{t('calendar.overdue')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.legendItem}>
|
<div style={styles.legendItem}>
|
||||||
<span style={{ ...styles.legendDot, backgroundColor: '#e8f5e9', border: '2px solid #4caf50' }} />
|
<span style={{ ...styles.legendDot, backgroundColor: '#e8f5e9', border: '2px solid #4caf50' }} />
|
||||||
<span style={styles.legendText}>Completed</span>
|
<span style={styles.legendText}>{t('calendar.completed')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.legendItem}>
|
<div style={styles.legendItem}>
|
||||||
<span style={styles.legendText}>Priority:</span>
|
<span style={styles.legendText}>{t('fields.priority')}:</span>
|
||||||
<span style={{ ...styles.priorityLabel, color: priorityColors.urgent }}>!!! Urgent</span>
|
<span style={{ ...styles.priorityLabel, color: priorityColors.urgent }}>!!! {t('priority.urgent')}</span>
|
||||||
<span style={{ ...styles.priorityLabel, color: priorityColors.high }}>!! High</span>
|
<span style={{ ...styles.priorityLabel, color: priorityColors.high }}>!! {t('priority.high')}</span>
|
||||||
<span style={{ ...styles.priorityLabel, color: priorityColors.medium }}>! Medium</span>
|
<span style={{ ...styles.priorityLabel, color: priorityColors.medium }}>! {t('priority.medium')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { commentsApi, usersApi, Comment, UserSearchResult } from '../services/collaboration'
|
import { commentsApi, usersApi, Comment, UserSearchResult } from '../services/collaboration'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { ConfirmModal } from './ConfirmModal'
|
import { ConfirmModal } from './ConfirmModal'
|
||||||
@@ -9,6 +10,7 @@ interface CommentsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Comments({ taskId }: CommentsProps) {
|
export function Comments({ taskId }: CommentsProps) {
|
||||||
|
const { t } = useTranslation('tasks')
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [comments, setComments] = useState<Comment[]>([])
|
const [comments, setComments] = useState<Comment[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -29,7 +31,7 @@ export function Comments({ taskId }: CommentsProps) {
|
|||||||
setComments(response.comments)
|
setComments(response.comments)
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to load comments')
|
setError(t('comments.error.load'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -55,7 +57,7 @@ export function Comments({ taskId }: CommentsProps) {
|
|||||||
setNewComment('')
|
setNewComment('')
|
||||||
setReplyTo(null)
|
setReplyTo(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to post comment')
|
setError(t('comments.error.post'))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -69,7 +71,7 @@ export function Comments({ taskId }: CommentsProps) {
|
|||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
setEditContent('')
|
setEditContent('')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to update comment')
|
setError(t('comments.error.update'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +81,7 @@ export function Comments({ taskId }: CommentsProps) {
|
|||||||
await fetchComments()
|
await fetchComments()
|
||||||
setDeleteConfirm(null)
|
setDeleteConfirm(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to delete comment')
|
setError(t('comments.error.delete'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +121,7 @@ export function Comments({ taskId }: CommentsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="font-semibold text-lg">Comments ({comments.length})</h3>
|
<h3 className="font-semibold text-lg">{t('comments.title')} ({comments.length})</h3>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-2 bg-red-100 text-red-700 rounded text-sm">{error}</div>
|
<div className="p-2 bg-red-100 text-red-700 rounded text-sm">{error}</div>
|
||||||
@@ -136,7 +138,7 @@ export function Comments({ taskId }: CommentsProps) {
|
|||||||
{new Date(comment.created_at).toLocaleString()}
|
{new Date(comment.created_at).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
{comment.is_edited && (
|
{comment.is_edited && (
|
||||||
<span className="text-gray-400 text-xs ml-1">(edited)</span>
|
<span className="text-gray-400 text-xs ml-1">({t('comments.edited')})</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{user?.id === comment.author.id && !comment.is_deleted && (
|
{user?.id === comment.author.id && !comment.is_deleted && (
|
||||||
@@ -148,13 +150,13 @@ export function Comments({ taskId }: CommentsProps) {
|
|||||||
}}
|
}}
|
||||||
className="text-sm text-blue-600 hover:underline"
|
className="text-sm text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
Edit
|
{t('common:buttons.edit')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteConfirm(comment.id)}
|
onClick={() => setDeleteConfirm(comment.id)}
|
||||||
className="text-sm text-red-600 hover:underline"
|
className="text-sm text-red-600 hover:underline"
|
||||||
>
|
>
|
||||||
Delete
|
{t('common:buttons.delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -173,13 +175,13 @@ export function Comments({ taskId }: CommentsProps) {
|
|||||||
onClick={() => handleEdit(comment.id)}
|
onClick={() => handleEdit(comment.id)}
|
||||||
className="px-3 py-1 bg-blue-600 text-white rounded text-sm"
|
className="px-3 py-1 bg-blue-600 text-white rounded text-sm"
|
||||||
>
|
>
|
||||||
Save
|
{t('common:buttons.save')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingId(null)}
|
onClick={() => setEditingId(null)}
|
||||||
className="px-3 py-1 bg-gray-300 rounded text-sm"
|
className="px-3 py-1 bg-gray-300 rounded text-sm"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common:buttons.cancel')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,7 +194,7 @@ export function Comments({ taskId }: CommentsProps) {
|
|||||||
{/* Mentions */}
|
{/* Mentions */}
|
||||||
{comment.mentions.length > 0 && (
|
{comment.mentions.length > 0 && (
|
||||||
<div className="mt-2 text-sm text-gray-500">
|
<div className="mt-2 text-sm text-gray-500">
|
||||||
Mentioned: {comment.mentions.map(m => m.name).join(', ')}
|
{t('comments.mentioned')}: {comment.mentions.map(m => m.name).join(', ')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -202,7 +204,7 @@ export function Comments({ taskId }: CommentsProps) {
|
|||||||
onClick={() => setReplyTo(comment.id)}
|
onClick={() => setReplyTo(comment.id)}
|
||||||
className="text-sm text-blue-600 hover:underline mt-2"
|
className="text-sm text-blue-600 hover:underline mt-2"
|
||||||
>
|
>
|
||||||
Reply {comment.reply_count > 0 && `(${comment.reply_count})`}
|
{t('comments.reply')} {comment.reply_count > 0 && `(${comment.reply_count})`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -213,13 +215,13 @@ export function Comments({ taskId }: CommentsProps) {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-2">
|
<form onSubmit={handleSubmit} className="space-y-2">
|
||||||
{replyTo && (
|
{replyTo && (
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
<span>Replying to comment</span>
|
<span>{t('comments.replyingTo')}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setReplyTo(null)}
|
onClick={() => setReplyTo(null)}
|
||||||
className="text-red-600 hover:underline"
|
className="text-red-600 hover:underline"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common:buttons.cancel')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -227,7 +229,7 @@ export function Comments({ taskId }: CommentsProps) {
|
|||||||
<textarea
|
<textarea
|
||||||
value={newComment}
|
value={newComment}
|
||||||
onChange={e => handleInputChange(e.target.value)}
|
onChange={e => handleInputChange(e.target.value)}
|
||||||
placeholder="Add a comment... Use @name to mention someone"
|
placeholder={t('comments.placeholder')}
|
||||||
className="w-full p-3 border rounded-lg resize-none"
|
className="w-full p-3 border rounded-lg resize-none"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
@@ -253,16 +255,16 @@ export function Comments({ taskId }: CommentsProps) {
|
|||||||
disabled={!newComment.trim() || submitting}
|
disabled={!newComment.trim() || submitting}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
|
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? 'Posting...' : 'Post Comment'}
|
{submitting ? t('comments.posting') : t('comments.add')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={deleteConfirm !== null}
|
isOpen={deleteConfirm !== null}
|
||||||
title="Delete Comment"
|
title={t('comments.delete')}
|
||||||
message="Are you sure you want to delete this comment? This action cannot be undone."
|
message={t('comments.confirmDelete')}
|
||||||
confirmText="Delete"
|
confirmText={t('common:buttons.delete')}
|
||||||
cancelText="Cancel"
|
cancelText={t('common:buttons.cancel')}
|
||||||
confirmStyle="danger"
|
confirmStyle="danger"
|
||||||
onConfirm={() => deleteConfirm && handleDelete(deleteConfirm)}
|
onConfirm={() => deleteConfirm && handleDelete(deleteConfirm)}
|
||||||
onCancel={() => setDeleteConfirm(null)}
|
onCancel={() => setDeleteConfirm(null)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { CustomValueResponse } from '../services/customFields'
|
import { CustomValueResponse } from '../services/customFields'
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
@@ -39,6 +40,7 @@ export function KanbanBoard({
|
|||||||
onStatusChange,
|
onStatusChange,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
}: KanbanBoardProps) {
|
}: KanbanBoardProps) {
|
||||||
|
const { t } = useTranslation('tasks')
|
||||||
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null)
|
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null)
|
||||||
const [dragOverColumnId, setDragOverColumnId] = useState<string | null>(null)
|
const [dragOverColumnId, setDragOverColumnId] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -135,7 +137,7 @@ export function KanbanBoard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{task.subtask_count > 0 && (
|
{task.subtask_count > 0 && (
|
||||||
<span style={styles.subtaskBadge}>{task.subtask_count} subtasks</span>
|
<span style={styles.subtaskBadge}>{t('subtasks.count', { count: task.subtask_count })}</span>
|
||||||
)}
|
)}
|
||||||
{/* Display custom field values (limit to first 2 for compact display) */}
|
{/* Display custom field values (limit to first 2 for compact display) */}
|
||||||
{task.custom_values?.slice(0, 2).map((cv) => (
|
{task.custom_values?.slice(0, 2).map((cv) => (
|
||||||
@@ -158,7 +160,7 @@ export function KanbanBoard({
|
|||||||
backgroundColor: '#9e9e9e',
|
backgroundColor: '#9e9e9e',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={styles.columnTitle}>No Status</span>
|
<span style={styles.columnTitle}>{t('status.noStatus')}</span>
|
||||||
<span style={styles.taskCount}>{unassignedTasks.length}</span>
|
<span style={styles.taskCount}>{unassignedTasks.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.taskList}>
|
<div style={styles.taskList}>
|
||||||
@@ -194,7 +196,7 @@ export function KanbanBoard({
|
|||||||
{tasksByStatus[status.id]?.map(renderTaskCard)}
|
{tasksByStatus[status.id]?.map(renderTaskCard)}
|
||||||
{(!tasksByStatus[status.id] || tasksByStatus[status.id].length === 0) && (
|
{(!tasksByStatus[status.id] || tasksByStatus[status.id].length === 0) && (
|
||||||
<div style={styles.emptyColumn}>
|
<div style={styles.emptyColumn}>
|
||||||
Drop tasks here
|
{t('kanban.dropHere')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
|
|
||||||
interface Subtask {
|
interface Subtask {
|
||||||
@@ -25,6 +26,7 @@ export function SubtaskList({
|
|||||||
onSubtaskClick,
|
onSubtaskClick,
|
||||||
onSubtaskCreated,
|
onSubtaskCreated,
|
||||||
}: SubtaskListProps) {
|
}: SubtaskListProps) {
|
||||||
|
const { t } = useTranslation('tasks')
|
||||||
const [subtasks, setSubtasks] = useState<Subtask[]>([])
|
const [subtasks, setSubtasks] = useState<Subtask[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -41,7 +43,7 @@ export function SubtaskList({
|
|||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch subtasks:', err)
|
console.error('Failed to fetch subtasks:', err)
|
||||||
setError('Failed to load subtasks')
|
setError(t('subtasks.error.load'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -69,7 +71,7 @@ export function SubtaskList({
|
|||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to create subtask:', err)
|
console.error('Failed to create subtask:', err)
|
||||||
const axiosError = err as { response?: { data?: { detail?: string } } }
|
const axiosError = err as { response?: { data?: { detail?: string } } }
|
||||||
const errorMessage = axiosError.response?.data?.detail || 'Failed to create subtask'
|
const errorMessage = axiosError.response?.data?.detail || t('subtasks.error.create')
|
||||||
setError(errorMessage)
|
setError(errorMessage)
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
@@ -99,7 +101,7 @@ export function SubtaskList({
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
aria-label={`Subtasks section, ${subtasks.length} items`}
|
aria-label={`${t('subtasks.title')}, ${subtasks.length}`}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -107,14 +109,14 @@ export function SubtaskList({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={styles.title}>Subtasks ({subtasks.length})</span>
|
<span style={styles.title}>{t('subtasks.title')} ({subtasks.length})</span>
|
||||||
<span style={styles.toggleIcon}>{expanded ? '\u25BC' : '\u25B6'}</span>
|
<span style={styles.toggleIcon}>{expanded ? '\u25BC' : '\u25B6'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div style={styles.content}>
|
<div style={styles.content}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={styles.loadingText}>Loading subtasks...</div>
|
<div style={styles.loadingText}>{t('common:labels.loading')}</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div style={styles.errorText}>{error}</div>
|
<div style={styles.errorText}>{error}</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -165,21 +167,21 @@ export function SubtaskList({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={styles.emptyText}>No subtasks yet</div>
|
<div style={styles.emptyText}>{t('subtasks.empty')}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Subtask Form */}
|
{/* Add Subtask Form */}
|
||||||
{showAddForm ? (
|
{showAddForm ? (
|
||||||
<form onSubmit={handleAddSubtask} style={styles.addForm}>
|
<form onSubmit={handleAddSubtask} style={styles.addForm}>
|
||||||
<label htmlFor="new-subtask-title" style={styles.visuallyHidden}>
|
<label htmlFor="new-subtask-title" style={styles.visuallyHidden}>
|
||||||
Subtask title
|
{t('subtasks.placeholder')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="new-subtask-title"
|
id="new-subtask-title"
|
||||||
type="text"
|
type="text"
|
||||||
value={newSubtaskTitle}
|
value={newSubtaskTitle}
|
||||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
||||||
placeholder="Enter subtask title..."
|
placeholder={t('subtasks.placeholder')}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
@@ -191,14 +193,14 @@ export function SubtaskList({
|
|||||||
style={styles.cancelButton}
|
style={styles.cancelButton}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common:buttons.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
style={styles.submitButton}
|
style={styles.submitButton}
|
||||||
disabled={!newSubtaskTitle.trim() || submitting}
|
disabled={!newSubtaskTitle.trim() || submitting}
|
||||||
>
|
>
|
||||||
{submitting ? 'Adding...' : 'Add'}
|
{submitting ? t('subtasks.adding') : t('common:buttons.add')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -207,7 +209,7 @@ export function SubtaskList({
|
|||||||
onClick={() => setShowAddForm(true)}
|
onClick={() => setShowAddForm(true)}
|
||||||
style={styles.addButton}
|
style={styles.addButton}
|
||||||
>
|
>
|
||||||
+ Add Subtask
|
+ {t('subtasks.add')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { Comments } from './Comments'
|
import { Comments } from './Comments'
|
||||||
import { TaskAttachments } from './TaskAttachments'
|
import { TaskAttachments } from './TaskAttachments'
|
||||||
@@ -50,6 +51,7 @@ export function TaskDetailModal({
|
|||||||
onUpdate,
|
onUpdate,
|
||||||
onSubtaskClick,
|
onSubtaskClick,
|
||||||
}: TaskDetailModalProps) {
|
}: TaskDetailModalProps) {
|
||||||
|
const { t } = useTranslation('tasks')
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState({
|
||||||
@@ -263,7 +265,7 @@ export function TaskDetailModal({
|
|||||||
<div style={styles.headerActions}>
|
<div style={styles.headerActions}>
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<button onClick={() => setIsEditing(true)} style={styles.editButton}>
|
<button onClick={() => setIsEditing(true)} style={styles.editButton}>
|
||||||
Edit
|
{t('common:buttons.edit')}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -272,18 +274,18 @@ export function TaskDetailModal({
|
|||||||
style={styles.cancelButton}
|
style={styles.cancelButton}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common:buttons.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
style={styles.saveButton}
|
style={styles.saveButton}
|
||||||
disabled={saving || !editForm.title.trim()}
|
disabled={saving || !editForm.title.trim()}
|
||||||
>
|
>
|
||||||
{saving ? 'Saving...' : 'Save'}
|
{saving ? t('common:labels.loading') : t('common:buttons.save')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">
|
<button onClick={onClose} style={styles.closeButton} aria-label={t('common:buttons.close')}>
|
||||||
X
|
X
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,7 +295,7 @@ export function TaskDetailModal({
|
|||||||
<div style={styles.mainSection}>
|
<div style={styles.mainSection}>
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div style={styles.field}>
|
<div style={styles.field}>
|
||||||
<label style={styles.fieldLabel}>Description</label>
|
<label style={styles.fieldLabel}>{t('fields.description')}</label>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<textarea
|
<textarea
|
||||||
value={editForm.description}
|
value={editForm.description}
|
||||||
@@ -301,11 +303,11 @@ export function TaskDetailModal({
|
|||||||
setEditForm({ ...editForm, description: e.target.value })
|
setEditForm({ ...editForm, description: e.target.value })
|
||||||
}
|
}
|
||||||
style={styles.textarea}
|
style={styles.textarea}
|
||||||
placeholder="Add a description..."
|
placeholder={t('fields.descriptionPlaceholder')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={styles.descriptionText}>
|
<div style={styles.descriptionText}>
|
||||||
{task.description || 'No description'}
|
{task.description || t('common:labels.noData')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -334,7 +336,7 @@ export function TaskDetailModal({
|
|||||||
<div style={styles.sidebar}>
|
<div style={styles.sidebar}>
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div style={styles.sidebarField}>
|
<div style={styles.sidebarField}>
|
||||||
<label style={styles.sidebarLabel}>Status</label>
|
<label style={styles.sidebarLabel}>{t('fields.status')}</label>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<select
|
<select
|
||||||
value={editForm.status_id}
|
value={editForm.status_id}
|
||||||
@@ -343,7 +345,7 @@ export function TaskDetailModal({
|
|||||||
}
|
}
|
||||||
style={styles.select}
|
style={styles.select}
|
||||||
>
|
>
|
||||||
<option value="">No Status</option>
|
<option value="">{t('status.noStatus')}</option>
|
||||||
{statuses.map((status) => (
|
{statuses.map((status) => (
|
||||||
<option key={status.id} value={status.id}>
|
<option key={status.id} value={status.id}>
|
||||||
{status.name}
|
{status.name}
|
||||||
@@ -357,14 +359,14 @@ export function TaskDetailModal({
|
|||||||
backgroundColor: task.status_color || '#e0e0e0',
|
backgroundColor: task.status_color || '#e0e0e0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{task.status_name || 'No Status'}
|
{task.status_name || t('status.noStatus')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Priority */}
|
{/* Priority */}
|
||||||
<div style={styles.sidebarField}>
|
<div style={styles.sidebarField}>
|
||||||
<label style={styles.sidebarLabel}>Priority</label>
|
<label style={styles.sidebarLabel}>{t('fields.priority')}</label>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<select
|
<select
|
||||||
value={editForm.priority}
|
value={editForm.priority}
|
||||||
@@ -373,10 +375,10 @@ export function TaskDetailModal({
|
|||||||
}
|
}
|
||||||
style={styles.select}
|
style={styles.select}
|
||||||
>
|
>
|
||||||
<option value="low">Low</option>
|
<option value="low">{t('priority.low')}</option>
|
||||||
<option value="medium">Medium</option>
|
<option value="medium">{t('priority.medium')}</option>
|
||||||
<option value="high">High</option>
|
<option value="high">{t('priority.high')}</option>
|
||||||
<option value="urgent">Urgent</option>
|
<option value="urgent">{t('priority.urgent')}</option>
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@@ -386,30 +388,30 @@ export function TaskDetailModal({
|
|||||||
color: getPriorityColor(task.priority),
|
color: getPriorityColor(task.priority),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}
|
{t(`priority.${task.priority}`)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Assignee */}
|
{/* Assignee */}
|
||||||
<div style={styles.sidebarField}>
|
<div style={styles.sidebarField}>
|
||||||
<label style={styles.sidebarLabel}>Assignee</label>
|
<label style={styles.sidebarLabel}>{t('fields.assignee')}</label>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<UserSelect
|
<UserSelect
|
||||||
value={editForm.assignee_id}
|
value={editForm.assignee_id}
|
||||||
onChange={handleAssigneeChange}
|
onChange={handleAssigneeChange}
|
||||||
placeholder="Select assignee..."
|
placeholder={t('common:labels.selectAssignee')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={styles.assigneeDisplay}>
|
<div style={styles.assigneeDisplay}>
|
||||||
{task.assignee_name || 'Unassigned'}
|
{task.assignee_name || t('status.unassigned')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Due Date */}
|
{/* Due Date */}
|
||||||
<div style={styles.sidebarField}>
|
<div style={styles.sidebarField}>
|
||||||
<label style={styles.sidebarLabel}>Due Date</label>
|
<label style={styles.sidebarLabel}>{t('fields.dueDate')}</label>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@@ -423,14 +425,14 @@ export function TaskDetailModal({
|
|||||||
<div style={styles.dueDateDisplay}>
|
<div style={styles.dueDateDisplay}>
|
||||||
{task.due_date
|
{task.due_date
|
||||||
? new Date(task.due_date).toLocaleDateString()
|
? new Date(task.due_date).toLocaleDateString()
|
||||||
: 'No due date'}
|
: t('status.noDueDate')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time Estimate */}
|
{/* Time Estimate */}
|
||||||
<div style={styles.sidebarField}>
|
<div style={styles.sidebarField}>
|
||||||
<label style={styles.sidebarLabel}>Time Estimate (hours)</label>
|
<label style={styles.sidebarLabel}>{t('fields.estimatedHours')}</label>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -445,7 +447,7 @@ export function TaskDetailModal({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={styles.timeEstimateDisplay}>
|
<div style={styles.timeEstimateDisplay}>
|
||||||
{task.time_estimate ? `${task.time_estimate} hours` : 'Not estimated'}
|
{task.time_estimate ? t('fields.hours', { count: task.time_estimate }) : t('status.notEstimated')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -453,8 +455,8 @@ export function TaskDetailModal({
|
|||||||
{/* Subtasks Info */}
|
{/* Subtasks Info */}
|
||||||
{task.subtask_count > 0 && (
|
{task.subtask_count > 0 && (
|
||||||
<div style={styles.sidebarField}>
|
<div style={styles.sidebarField}>
|
||||||
<label style={styles.sidebarLabel}>Subtasks</label>
|
<label style={styles.sidebarLabel}>{t('subtasks.title')}</label>
|
||||||
<div style={styles.subtaskInfo}>{task.subtask_count} subtask(s)</div>
|
<div style={styles.subtaskInfo}>{t('subtasks.count', { count: task.subtask_count })}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -462,7 +464,7 @@ export function TaskDetailModal({
|
|||||||
{customFields.length > 0 && (
|
{customFields.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div style={styles.customFieldsDivider} />
|
<div style={styles.customFieldsDivider} />
|
||||||
<div style={styles.customFieldsHeader}>Custom Fields</div>
|
<div style={styles.customFieldsHeader}>{t('settings:customFields.title')}</div>
|
||||||
{loadingCustomFields ? (
|
{loadingCustomFields ? (
|
||||||
<SkeletonList count={3} showAvatar={false} />
|
<SkeletonList count={3} showAvatar={false} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ProjectHealthCard } from '../components/ProjectHealthCard'
|
import { ProjectHealthCard } from '../components/ProjectHealthCard'
|
||||||
import { SkeletonGrid } from '../components/Skeleton'
|
import { SkeletonGrid } from '../components/Skeleton'
|
||||||
import {
|
import {
|
||||||
@@ -11,14 +12,6 @@ import {
|
|||||||
|
|
||||||
type SortOption = 'risk_high' | 'risk_low' | 'health_high' | 'health_low' | 'name'
|
type SortOption = 'risk_high' | 'risk_low' | 'health_high' | 'health_low' | 'name'
|
||||||
|
|
||||||
const sortOptions: { value: SortOption; label: string }[] = [
|
|
||||||
{ value: 'risk_high', label: 'Risk: High to Low' },
|
|
||||||
{ value: 'risk_low', label: 'Risk: Low to High' },
|
|
||||||
{ value: 'health_high', label: 'Health: High to Low' },
|
|
||||||
{ value: 'health_low', label: 'Health: Low to High' },
|
|
||||||
{ value: 'name', label: 'Name: A to Z' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Risk level priority for sorting (higher number = higher risk)
|
// Risk level priority for sorting (higher number = higher risk)
|
||||||
const riskLevelPriority: Record<RiskLevel, number> = {
|
const riskLevelPriority: Record<RiskLevel, number> = {
|
||||||
low: 1,
|
low: 1,
|
||||||
@@ -28,6 +21,7 @@ const riskLevelPriority: Record<RiskLevel, number> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectHealthPage() {
|
export default function ProjectHealthPage() {
|
||||||
|
const { t } = useTranslation('health')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -42,7 +36,7 @@ export default function ProjectHealthPage() {
|
|||||||
setDashboardData(data)
|
setDashboardData(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load project health dashboard:', err)
|
console.error('Failed to load project health dashboard:', err)
|
||||||
setError('Failed to load project health data. Please try again.')
|
setError(t('error.loadFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -99,9 +93,9 @@ export default function ProjectHealthPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={styles.title}>Project Health Dashboard</h1>
|
<h1 style={styles.title}>{t('title')}</h1>
|
||||||
<p style={styles.subtitle}>
|
<p style={styles.subtitle}>
|
||||||
Monitor project health status and risk levels across all projects
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,25 +105,25 @@ export default function ProjectHealthPage() {
|
|||||||
<div style={styles.summaryContainer}>
|
<div style={styles.summaryContainer}>
|
||||||
<div style={styles.summaryCard}>
|
<div style={styles.summaryCard}>
|
||||||
<span style={styles.summaryValue}>{dashboardData.summary.total_projects}</span>
|
<span style={styles.summaryValue}>{dashboardData.summary.total_projects}</span>
|
||||||
<span style={styles.summaryLabel}>Total Projects</span>
|
<span style={styles.summaryLabel}>{t('summary.totalProjects')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.summaryCard}>
|
<div style={styles.summaryCard}>
|
||||||
<span style={{ ...styles.summaryValue, color: '#4caf50' }}>
|
<span style={{ ...styles.summaryValue, color: '#4caf50' }}>
|
||||||
{dashboardData.summary.healthy_count}
|
{dashboardData.summary.healthy_count}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.summaryLabel}>Healthy</span>
|
<span style={styles.summaryLabel}>{t('summary.healthy')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.summaryCard}>
|
<div style={styles.summaryCard}>
|
||||||
<span style={{ ...styles.summaryValue, color: '#ff9800' }}>
|
<span style={{ ...styles.summaryValue, color: '#ff9800' }}>
|
||||||
{dashboardData.summary.at_risk_count}
|
{dashboardData.summary.at_risk_count}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.summaryLabel}>At Risk</span>
|
<span style={styles.summaryLabel}>{t('summary.atRisk')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.summaryCard}>
|
<div style={styles.summaryCard}>
|
||||||
<span style={{ ...styles.summaryValue, color: '#f44336' }}>
|
<span style={{ ...styles.summaryValue, color: '#f44336' }}>
|
||||||
{dashboardData.summary.critical_count}
|
{dashboardData.summary.critical_count}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.summaryLabel}>Critical</span>
|
<span style={styles.summaryLabel}>{t('summary.critical')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.summaryCard}>
|
<div style={styles.summaryCard}>
|
||||||
<span
|
<span
|
||||||
@@ -140,19 +134,19 @@ export default function ProjectHealthPage() {
|
|||||||
>
|
>
|
||||||
{Math.round(dashboardData.summary.average_health_score)}
|
{Math.round(dashboardData.summary.average_health_score)}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.summaryLabel}>Avg. Health</span>
|
<span style={styles.summaryLabel}>{t('summary.avgHealth')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.summaryCard}>
|
<div style={styles.summaryCard}>
|
||||||
<span style={{ ...styles.summaryValue, color: dashboardData.summary.projects_with_blockers > 0 ? '#f44336' : '#666' }}>
|
<span style={{ ...styles.summaryValue, color: dashboardData.summary.projects_with_blockers > 0 ? '#f44336' : '#666' }}>
|
||||||
{dashboardData.summary.projects_with_blockers}
|
{dashboardData.summary.projects_with_blockers}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.summaryLabel}>With Blockers</span>
|
<span style={styles.summaryLabel}>{t('summary.withBlockers')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.summaryCard}>
|
<div style={styles.summaryCard}>
|
||||||
<span style={{ ...styles.summaryValue, color: dashboardData.summary.projects_delayed > 0 ? '#ff9800' : '#666' }}>
|
<span style={{ ...styles.summaryValue, color: dashboardData.summary.projects_delayed > 0 ? '#ff9800' : '#666' }}>
|
||||||
{dashboardData.summary.projects_delayed}
|
{dashboardData.summary.projects_delayed}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.summaryLabel}>Delayed</span>
|
<span style={styles.summaryLabel}>{t('summary.delayed')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -162,7 +156,7 @@ export default function ProjectHealthPage() {
|
|||||||
<div style={styles.controlsContainer}>
|
<div style={styles.controlsContainer}>
|
||||||
<div style={styles.sortControl}>
|
<div style={styles.sortControl}>
|
||||||
<label htmlFor="sort-select" style={styles.sortLabel}>
|
<label htmlFor="sort-select" style={styles.sortLabel}>
|
||||||
Sort by:
|
{t('sort.label')}:
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="sort-select"
|
id="sort-select"
|
||||||
@@ -170,15 +164,15 @@ export default function ProjectHealthPage() {
|
|||||||
onChange={handleSortChange}
|
onChange={handleSortChange}
|
||||||
style={styles.sortSelect}
|
style={styles.sortSelect}
|
||||||
>
|
>
|
||||||
{sortOptions.map((option) => (
|
<option value="risk_high">{t('sort.riskHigh')}</option>
|
||||||
<option key={option.value} value={option.value}>
|
<option value="risk_low">{t('sort.riskLow')}</option>
|
||||||
{option.label}
|
<option value="health_high">{t('sort.healthHigh')}</option>
|
||||||
</option>
|
<option value="health_low">{t('sort.healthLow')}</option>
|
||||||
))}
|
<option value="name">{t('sort.name')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<span style={styles.projectCount}>
|
<span style={styles.projectCount}>
|
||||||
{dashboardData.projects.length} project{dashboardData.projects.length !== 1 ? 's' : ''}
|
{t('projectCount', { count: dashboardData.projects.length })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -190,14 +184,14 @@ export default function ProjectHealthPage() {
|
|||||||
<div style={styles.errorContainer}>
|
<div style={styles.errorContainer}>
|
||||||
<p style={styles.error}>{error}</p>
|
<p style={styles.error}>{error}</p>
|
||||||
<button onClick={loadDashboard} style={styles.retryButton}>
|
<button onClick={loadDashboard} style={styles.retryButton}>
|
||||||
Retry
|
{t('actions.retry')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : sortedProjects.length === 0 ? (
|
) : sortedProjects.length === 0 ? (
|
||||||
<div style={styles.emptyContainer}>
|
<div style={styles.emptyContainer}>
|
||||||
<p style={styles.emptyText}>No projects found.</p>
|
<p style={styles.emptyText}>{t('empty.title')}</p>
|
||||||
<p style={styles.emptySubtext}>
|
<p style={styles.emptySubtext}>
|
||||||
Create a project to start tracking health status.
|
{t('empty.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { CustomFieldList } from '../components/CustomFieldList'
|
import { CustomFieldList } from '../components/CustomFieldList'
|
||||||
import { useToast } from '../contexts/ToastContext'
|
import { useToast } from '../contexts/ToastContext'
|
||||||
@@ -15,6 +16,7 @@ interface Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectSettings() {
|
export default function ProjectSettings() {
|
||||||
|
const { t } = useTranslation('settings')
|
||||||
const { projectId } = useParams()
|
const { projectId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
@@ -32,7 +34,7 @@ export default function ProjectSettings() {
|
|||||||
setProject(response.data)
|
setProject(response.data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load project:', err)
|
console.error('Failed to load project:', err)
|
||||||
showToast('Failed to load project settings', 'error')
|
showToast(t('common:messages.error'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -59,21 +61,21 @@ export default function ProjectSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return <div style={styles.error}>Project not found</div>
|
return <div style={styles.error}>{t('common:messages.notFound')}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<div style={styles.breadcrumb}>
|
<div style={styles.breadcrumb}>
|
||||||
<span onClick={() => navigate('/spaces')} style={styles.breadcrumbLink}>
|
<span onClick={() => navigate('/spaces')} style={styles.breadcrumbLink}>
|
||||||
Spaces
|
{t('common:nav.spaces')}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.breadcrumbSeparator}>/</span>
|
<span style={styles.breadcrumbSeparator}>/</span>
|
||||||
<span
|
<span
|
||||||
onClick={() => navigate(`/spaces/${project.space_id}`)}
|
onClick={() => navigate(`/spaces/${project.space_id}`)}
|
||||||
style={styles.breadcrumbLink}
|
style={styles.breadcrumbLink}
|
||||||
>
|
>
|
||||||
Projects
|
{t('common:nav.projects')}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.breadcrumbSeparator}>/</span>
|
<span style={styles.breadcrumbSeparator}>/</span>
|
||||||
<span
|
<span
|
||||||
@@ -83,16 +85,16 @@ export default function ProjectSettings() {
|
|||||||
{project.title}
|
{project.title}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.breadcrumbSeparator}>/</span>
|
<span style={styles.breadcrumbSeparator}>/</span>
|
||||||
<span>Settings</span>
|
<span>{t('title')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
<h1 style={styles.title}>Project Settings</h1>
|
<h1 style={styles.title}>{t('projectSettings')}</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/projects/${project.id}`)}
|
onClick={() => navigate(`/projects/${project.id}`)}
|
||||||
style={styles.backButton}
|
style={styles.backButton}
|
||||||
>
|
>
|
||||||
Back to Tasks
|
{t('backToTasks')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -107,7 +109,7 @@ export default function ProjectSettings() {
|
|||||||
...(activeTab === 'general' ? styles.navItemActive : {}),
|
...(activeTab === 'general' ? styles.navItemActive : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
General
|
{t('tabs.general')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('custom-fields')}
|
onClick={() => setActiveTab('custom-fields')}
|
||||||
@@ -116,7 +118,7 @@ export default function ProjectSettings() {
|
|||||||
...(activeTab === 'custom-fields' ? styles.navItemActive : {}),
|
...(activeTab === 'custom-fields' ? styles.navItemActive : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Custom Fields
|
{t('tabs.customFields')}
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,25 +127,25 @@ export default function ProjectSettings() {
|
|||||||
<div style={styles.content}>
|
<div style={styles.content}>
|
||||||
{activeTab === 'general' && (
|
{activeTab === 'general' && (
|
||||||
<div style={styles.section}>
|
<div style={styles.section}>
|
||||||
<h2 style={styles.sectionTitle}>General Settings</h2>
|
<h2 style={styles.sectionTitle}>{t('general.title')}</h2>
|
||||||
<div style={styles.infoCard}>
|
<div style={styles.infoCard}>
|
||||||
<div style={styles.infoRow}>
|
<div style={styles.infoRow}>
|
||||||
<span style={styles.infoLabel}>Project Name</span>
|
<span style={styles.infoLabel}>{t('general.projectName')}</span>
|
||||||
<span style={styles.infoValue}>{project.title}</span>
|
<span style={styles.infoValue}>{project.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.infoRow}>
|
<div style={styles.infoRow}>
|
||||||
<span style={styles.infoLabel}>Description</span>
|
<span style={styles.infoLabel}>{t('general.description')}</span>
|
||||||
<span style={styles.infoValue}>
|
<span style={styles.infoValue}>
|
||||||
{project.description || 'No description'}
|
{project.description || t('general.noDescription')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.infoRow}>
|
<div style={styles.infoRow}>
|
||||||
<span style={styles.infoLabel}>Security Level</span>
|
<span style={styles.infoLabel}>{t('general.securityLevel')}</span>
|
||||||
<span style={styles.infoValue}>{project.security_level}</span>
|
<span style={styles.infoValue}>{project.security_level}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p style={styles.helpText}>
|
<p style={styles.helpText}>
|
||||||
To edit project details, contact the project owner.
|
{t('general.helpText')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { SkeletonGrid } from '../components/Skeleton'
|
import { SkeletonGrid } from '../components/Skeleton'
|
||||||
import { useToast } from '../contexts/ToastContext'
|
import { useToast } from '../contexts/ToastContext'
|
||||||
@@ -23,6 +24,7 @@ interface Space {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Projects() {
|
export default function Projects() {
|
||||||
|
const { t } = useTranslation('projects')
|
||||||
const { spaceId } = useParams()
|
const { spaceId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
@@ -71,7 +73,7 @@ export default function Projects() {
|
|||||||
setProjects(projectsRes.data)
|
setProjects(projectsRes.data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load data:', err)
|
console.error('Failed to load data:', err)
|
||||||
showToast('Failed to load projects', 'error')
|
showToast(t('messages.loadFailed'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -86,10 +88,10 @@ export default function Projects() {
|
|||||||
setShowCreateModal(false)
|
setShowCreateModal(false)
|
||||||
setNewProject({ title: '', description: '', security_level: 'department' })
|
setNewProject({ title: '', description: '', security_level: 'department' })
|
||||||
loadData()
|
loadData()
|
||||||
showToast('Project created successfully', 'success')
|
showToast(t('messages.created'), 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to create project:', err)
|
console.error('Failed to create project:', err)
|
||||||
showToast('Failed to create project', 'error')
|
showToast(t('messages.createFailed'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
}
|
}
|
||||||
@@ -125,16 +127,16 @@ export default function Projects() {
|
|||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<div style={styles.breadcrumb}>
|
<div style={styles.breadcrumb}>
|
||||||
<span onClick={() => navigate('/spaces')} style={styles.breadcrumbLink}>
|
<span onClick={() => navigate('/spaces')} style={styles.breadcrumbLink}>
|
||||||
Spaces
|
{t('common:nav.spaces')}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.breadcrumbSeparator}>/</span>
|
<span style={styles.breadcrumbSeparator}>/</span>
|
||||||
<span>{space?.name}</span>
|
<span>{space?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
<h1 style={styles.title}>Projects</h1>
|
<h1 style={styles.title}>{t('title')}</h1>
|
||||||
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
|
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
|
||||||
+ New Project
|
+ {t('newProject')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -152,7 +154,7 @@ export default function Projects() {
|
|||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={`Open project: ${project.title}`}
|
aria-label={`${t('title')}: ${project.title}`}
|
||||||
>
|
>
|
||||||
<div style={styles.cardHeader}>
|
<div style={styles.cardHeader}>
|
||||||
<h3 style={styles.cardTitle}>{project.title}</h3>
|
<h3 style={styles.cardTitle}>{project.title}</h3>
|
||||||
@@ -161,18 +163,18 @@ export default function Projects() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={styles.cardDescription}>
|
<p style={styles.cardDescription}>
|
||||||
{project.description || 'No description'}
|
{project.description || t('card.noDescription')}
|
||||||
</p>
|
</p>
|
||||||
<div style={styles.cardMeta}>
|
<div style={styles.cardMeta}>
|
||||||
<span>{project.task_count} tasks</span>
|
<span>{t('card.tasks', { count: project.task_count })}</span>
|
||||||
<span>Owner: {project.owner_name || 'Unknown'}</span>
|
<span>{t('card.owner')}: {project.owner_name || t('card.unknown')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{projects.length === 0 && (
|
{projects.length === 0 && (
|
||||||
<div style={styles.empty}>
|
<div style={styles.empty}>
|
||||||
<p>No projects yet. Create your first project!</p>
|
<p>{t('empty.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -187,49 +189,49 @@ export default function Projects() {
|
|||||||
aria-labelledby="create-project-title"
|
aria-labelledby="create-project-title"
|
||||||
>
|
>
|
||||||
<div style={styles.modal}>
|
<div style={styles.modal}>
|
||||||
<h2 id="create-project-title" style={styles.modalTitle}>Create New Project</h2>
|
<h2 id="create-project-title" style={styles.modalTitle}>{t('createProject')}</h2>
|
||||||
<label htmlFor="project-title" style={styles.visuallyHidden}>
|
<label htmlFor="project-title" style={styles.visuallyHidden}>
|
||||||
Project title
|
{t('fields.title')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="project-title"
|
id="project-title"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Project title"
|
placeholder={t('fields.titlePlaceholder')}
|
||||||
value={newProject.title}
|
value={newProject.title}
|
||||||
onChange={(e) => setNewProject({ ...newProject, title: e.target.value })}
|
onChange={(e) => setNewProject({ ...newProject, title: e.target.value })}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<label htmlFor="project-description" style={styles.visuallyHidden}>
|
<label htmlFor="project-description" style={styles.visuallyHidden}>
|
||||||
Description
|
{t('fields.description')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="project-description"
|
id="project-description"
|
||||||
placeholder="Description (optional)"
|
placeholder={t('fields.descriptionPlaceholder')}
|
||||||
value={newProject.description}
|
value={newProject.description}
|
||||||
onChange={(e) => setNewProject({ ...newProject, description: e.target.value })}
|
onChange={(e) => setNewProject({ ...newProject, description: e.target.value })}
|
||||||
style={styles.textarea}
|
style={styles.textarea}
|
||||||
/>
|
/>
|
||||||
<label style={styles.label}>Security Level</label>
|
<label style={styles.label}>{t('securityLevel.label')}</label>
|
||||||
<select
|
<select
|
||||||
value={newProject.security_level}
|
value={newProject.security_level}
|
||||||
onChange={(e) => setNewProject({ ...newProject, security_level: e.target.value })}
|
onChange={(e) => setNewProject({ ...newProject, security_level: e.target.value })}
|
||||||
style={styles.select}
|
style={styles.select}
|
||||||
>
|
>
|
||||||
<option value="public">Public - All users</option>
|
<option value="public">{t('securityLevel.public')}</option>
|
||||||
<option value="department">Department - Same department only</option>
|
<option value="department">{t('securityLevel.department')}</option>
|
||||||
<option value="confidential">Confidential - Owner only</option>
|
<option value="confidential">{t('securityLevel.confidential')}</option>
|
||||||
</select>
|
</select>
|
||||||
<div style={styles.modalActions}>
|
<div style={styles.modalActions}>
|
||||||
<button onClick={() => setShowCreateModal(false)} style={styles.cancelButton}>
|
<button onClick={() => setShowCreateModal(false)} style={styles.cancelButton}>
|
||||||
Cancel
|
{t('common:buttons.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateProject}
|
onClick={handleCreateProject}
|
||||||
disabled={creating || !newProject.title.trim()}
|
disabled={creating || !newProject.title.trim()}
|
||||||
style={styles.submitButton}
|
style={styles.submitButton}
|
||||||
>
|
>
|
||||||
{creating ? 'Creating...' : 'Create'}
|
{creating ? t('common:labels.loading') : t('common:buttons.create')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ export default function Tasks() {
|
|||||||
payload.due_date = newTask.due_date
|
payload.due_date = newTask.due_date
|
||||||
}
|
}
|
||||||
if (newTask.time_estimate) {
|
if (newTask.time_estimate) {
|
||||||
payload.time_estimate = Number(newTask.time_estimate)
|
payload.original_estimate = Number(newTask.time_estimate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include custom field values (only non-formula fields)
|
// Include custom field values (only non-formula fields)
|
||||||
|
|||||||
Reference in New Issue
Block a user