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:
beabigegg
2026-01-08 23:38:21 +08:00
parent 4bc3c24360
commit 2796cbb42d
20 changed files with 401 additions and 197 deletions

View File

@@ -52,7 +52,8 @@
"selectAssignee": "Select assignee...",
"searchUsers": "Search users...",
"noUsersFound": "No users found",
"typeToSearch": "Type to search users"
"typeToSearch": "Type to search users",
"task": "Task"
},
"messages": {
"success": "Operation successful",

View File

@@ -1,12 +1,29 @@
{
"title": "Project Health",
"subtitle": "Monitor the overall health of projects",
"title": "Project Health Dashboard",
"subtitle": "Monitor project health status and risk levels across all projects",
"overall": {
"title": "Overall Health",
"healthy": "Healthy",
"atRisk": "At Risk",
"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": {
"schedule": "Schedule",
"budget": "Budget",
@@ -34,15 +51,21 @@
"high": "High Risk",
"medium": "Medium Risk",
"low": "Low Risk",
"critical": "Critical",
"mitigated": "Mitigated"
},
"actions": {
"viewDetails": "View Details",
"exportReport": "Export Report",
"setAlert": "Set Alert"
"setAlert": "Set Alert",
"retry": "Retry"
},
"projectCount": "{{count}} project(s)",
"empty": {
"title": "No Health Data",
"description": "The project needs more data to display health metrics"
"title": "No projects found",
"description": "Create a project to start tracking health status"
},
"error": {
"loadFailed": "Failed to load project health data. Please try again."
}
}

View File

@@ -4,16 +4,26 @@
"editProject": "Edit Project",
"deleteProject": "Delete Project",
"projectSettings": "Project Settings",
"newProject": "New Project",
"fields": {
"name": "Name",
"namePlaceholder": "Enter project name",
"title": "Project Title",
"titlePlaceholder": "Enter project title",
"description": "Description",
"descriptionPlaceholder": "Enter project description",
"descriptionPlaceholder": "Description (optional)",
"status": "Status",
"startDate": "Start Date",
"endDate": "End Date",
"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": {
"planning": "Planning",
@@ -37,10 +47,18 @@
"overdue": "Overdue",
"progress": "Overall Progress"
},
"card": {
"tasks": "{{count}} tasks",
"owner": "Owner",
"noDescription": "No description",
"unknown": "Unknown"
},
"messages": {
"created": "Project created",
"created": "Project created successfully",
"updated": "Project updated",
"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."
},
"empty": {

View File

@@ -1,6 +1,7 @@
{
"title": "Settings",
"projectSettings": "Project Settings",
"backToTasks": "Back to Tasks",
"tabs": {
"general": "General",
"members": "Members",
@@ -13,10 +14,13 @@
"title": "General Settings",
"projectName": "Project Name",
"description": "Description",
"noDescription": "No description",
"securityLevel": "Security Level",
"status": "Status",
"visibility": "Visibility",
"public": "Public",
"private": "Private"
"private": "Private",
"helpText": "To edit project details, contact the project owner."
},
"members": {
"title": "Member Management",

View File

@@ -23,7 +23,8 @@
"attachments": "Attachments",
"comments": "Comments",
"watchers": "Watchers",
"blockers": "Blockers"
"blockers": "Blockers",
"hours": "{{count}} hours"
},
"status": {
"todo": "To Do",
@@ -31,7 +32,11 @@
"review": "In Review",
"done": "Done",
"cancelled": "Cancelled",
"blocked": "Blocked"
"blocked": "Blocked",
"noStatus": "No Status",
"unassigned": "Unassigned",
"noDueDate": "No due date",
"notEstimated": "Not estimated"
},
"priority": {
"low": "Low",
@@ -86,17 +91,32 @@
"add": "Add Subtask",
"placeholder": "Enter subtask title",
"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": {
"title": "Comments",
"add": "Add Comment",
"placeholder": "Write your comment...",
"add": "Post Comment",
"placeholder": "Add a comment... Use @name to mention someone",
"edited": "edited",
"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",
"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": {
"title": "Attachments",
@@ -113,7 +133,26 @@
"blockedBy": "Blocked by",
"blocking": "Blocking",
"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": {
"created": "Task created",
@@ -126,6 +165,18 @@
"attachmentUploaded": "Attachment uploaded",
"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": {
"title": "No Tasks",
"description": "There are no tasks yet. Create your first task to get started!",

View File

@@ -52,7 +52,8 @@
"selectAssignee": "選擇負責人...",
"searchUsers": "搜尋使用者...",
"noUsersFound": "找不到使用者",
"typeToSearch": "輸入以搜尋使用者"
"typeToSearch": "輸入以搜尋使用者",
"task": "任務"
},
"messages": {
"success": "操作成功",

View File

@@ -1,12 +1,29 @@
{
"title": "專案健康度",
"subtitle": "監控專案的整體健康狀況",
"title": "專案健康度儀表板",
"subtitle": "監控所有專案的健康狀況與風險等級",
"overall": {
"title": "整體健康度",
"healthy": "健康",
"atRisk": "風險中",
"critical": "危急"
},
"summary": {
"totalProjects": "專案總數",
"healthy": "健康",
"atRisk": "風險中",
"critical": "危急",
"avgHealth": "平均健康度",
"withBlockers": "有阻擋問題",
"delayed": "延遲"
},
"sort": {
"label": "排序方式",
"riskHigh": "風險:高到低",
"riskLow": "風險:低到高",
"healthHigh": "健康度:高到低",
"healthLow": "健康度:低到高",
"name": "名稱A 到 Z"
},
"metrics": {
"schedule": "進度",
"budget": "預算",
@@ -34,15 +51,21 @@
"high": "高風險",
"medium": "中風險",
"low": "低風險",
"critical": "危急",
"mitigated": "已緩解"
},
"actions": {
"viewDetails": "查看詳情",
"exportReport": "匯出報告",
"setAlert": "設定警示"
"setAlert": "設定警示",
"retry": "重試"
},
"projectCount": "{{count}} 個專案",
"empty": {
"title": "沒有健康度資料",
"description": "專案需要更多資料才能顯示健康度指標"
"title": "沒有專案",
"description": "建立專案以開始追蹤健康狀態"
},
"error": {
"loadFailed": "載入專案健康度資料失敗,請重試。"
}
}

View File

@@ -4,16 +4,26 @@
"editProject": "編輯專案",
"deleteProject": "刪除專案",
"projectSettings": "專案設定",
"newProject": "新增專案",
"fields": {
"name": "名稱",
"namePlaceholder": "輸入專案名稱",
"title": "專案標題",
"titlePlaceholder": "輸入專案標題",
"description": "描述",
"descriptionPlaceholder": "輸入專案描述",
"descriptionPlaceholder": "輸入專案描述(選填)",
"status": "狀態",
"startDate": "開始日期",
"endDate": "結束日期",
"owner": "專案負責人",
"space": "工作空間"
"space": "工作空間",
"securityLevel": "安全等級"
},
"securityLevel": {
"label": "安全等級",
"public": "公開 - 所有使用者",
"department": "部門 - 僅同部門",
"confidential": "機密 - 僅負責人"
},
"status": {
"planning": "規劃中",
@@ -37,10 +47,18 @@
"overdue": "逾期",
"progress": "整體進度"
},
"card": {
"tasks": "{{count}} 個任務",
"owner": "負責人",
"noDescription": "沒有描述",
"unknown": "未知"
},
"messages": {
"created": "專案已建立",
"updated": "專案已更新",
"deleted": "專案已刪除",
"loadFailed": "載入專案失敗",
"createFailed": "建立專案失敗",
"confirmDelete": "確定要刪除此專案嗎?此操作將刪除所有相關任務。"
},
"empty": {

View File

@@ -1,6 +1,7 @@
{
"title": "設定",
"projectSettings": "專案設定",
"backToTasks": "返回任務",
"tabs": {
"general": "一般",
"members": "成員",
@@ -13,10 +14,13 @@
"title": "一般設定",
"projectName": "專案名稱",
"description": "描述",
"noDescription": "沒有描述",
"securityLevel": "安全等級",
"status": "狀態",
"visibility": "可見性",
"public": "公開",
"private": "私人"
"private": "私人",
"helpText": "如需編輯專案詳情,請聯繫專案負責人。"
},
"members": {
"title": "成員管理",

View File

@@ -23,7 +23,8 @@
"attachments": "附件",
"comments": "留言",
"watchers": "關注者",
"blockers": "阻擋項目"
"blockers": "阻擋項目",
"hours": "{{count}} 小時"
},
"status": {
"todo": "待處理",
@@ -31,7 +32,11 @@
"review": "審核中",
"done": "已完成",
"cancelled": "已取消",
"blocked": "被阻擋"
"blocked": "被阻擋",
"noStatus": "無狀態",
"unassigned": "未指派",
"noDueDate": "無截止日期",
"notEstimated": "未預估"
},
"priority": {
"low": "低",
@@ -86,17 +91,32 @@
"add": "新增子任務",
"placeholder": "輸入子任務標題",
"completed": "已完成 {{count}} / {{total}}",
"empty": "沒有子任務"
"count": "{{count}} 個子任務",
"empty": "沒有子任務",
"adding": "新增中...",
"error": {
"load": "載入子任務失敗",
"create": "建立子任務失敗"
}
},
"comments": {
"title": "留言",
"add": "新增留言",
"placeholder": "輸入您的留言...",
"add": "發表留言",
"placeholder": "新增留言... 使用 @name 來提及某人",
"edited": "已編輯",
"delete": "刪除留言",
"confirmDelete": "確定要刪除此留言嗎?",
"confirmDelete": "確定要刪除此留言嗎?此操作無法復原。",
"empty": "還沒有留言",
"reply": "回覆"
"reply": "回覆",
"mentioned": "提及",
"replyingTo": "回覆留言中",
"posting": "發送中...",
"error": {
"load": "載入留言失敗",
"post": "發表留言失敗",
"update": "更新留言失敗",
"delete": "刪除留言失敗"
}
},
"attachments": {
"title": "附件",
@@ -113,7 +133,26 @@
"blockedBy": "被以下任務阻擋",
"blocking": "正在阻擋以下任務",
"remove": "移除阻擋關係",
"empty": "沒有阻擋項目"
"empty": "沒有阻擋項目",
"taskBlocked": "任務被阻擋",
"markAsBlocked": "標記為阻擋",
"activeBlocker": "進行中的阻擋",
"reportedBy": "回報者",
"on": "於",
"resolutionNote": "解決說明",
"resolutionPlaceholder": "描述如何解決阻擋問題...",
"resolving": "解決中...",
"resolveBlocker": "解決阻擋",
"blockerReason": "阻擋原因",
"reasonPlaceholder": "描述阻擋此任務的原因...",
"history": "阻擋歷史",
"reported": "回報",
"resolved": "已解決",
"error": {
"load": "載入阻擋項目失敗",
"create": "建立阻擋項目失敗",
"resolve": "解決阻擋項目失敗"
}
},
"messages": {
"created": "任務已建立",
@@ -126,6 +165,18 @@
"attachmentUploaded": "附件已上傳",
"confirmDelete": "確定要刪除此任務嗎?此操作無法復原。"
},
"kanban": {
"dropHere": "將任務拖放至此"
},
"calendar": {
"allAssignees": "所有負責人",
"allPriorities": "所有優先順序",
"activeOnly": "僅進行中",
"completedOnly": "僅已完成",
"clearFilters": "清除篩選",
"overdue": "逾期",
"completed": "已完成"
},
"empty": {
"title": "沒有任務",
"description": "目前沒有任務。建立您的第一個任務開始吧!",

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { blockersApi, Blocker } from '../services/collaboration'
import { SkeletonList } from './Skeleton'
@@ -17,6 +18,7 @@ export function BlockerDialog({
onClose,
onBlockerChange,
}: BlockerDialogProps) {
const { t } = useTranslation('tasks')
const [blockers, setBlockers] = useState<Blocker[]>([])
const [loading, setLoading] = useState(true)
const [reason, setReason] = useState('')
@@ -34,7 +36,7 @@ export function BlockerDialog({
const active = response.blockers.find(b => !b.resolved_at)
setActiveBlocker(active || null)
} catch (err) {
setError('Failed to load blockers')
setError(t('blockers.error.load'))
} finally {
setLoading(false)
}
@@ -55,7 +57,7 @@ export function BlockerDialog({
setError(null)
onBlockerChange()
} catch (err) {
setError('Failed to create blocker')
setError(t('blockers.error.create'))
} finally {
setSubmitting(false)
}
@@ -74,7 +76,7 @@ export function BlockerDialog({
setError(null)
onBlockerChange()
} catch (err) {
setError('Failed to resolve blocker')
setError(t('blockers.error.resolve'))
} finally {
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="p-4 border-b flex justify-between items-center">
<h2 className="text-lg font-semibold">
{isBlocked ? 'Task Blocked' : 'Mark as Blocked'}
{isBlocked ? t('blockers.taskBlocked') : t('blockers.markAsBlocked')}
</h2>
<button
onClick={onClose}
@@ -96,7 +98,7 @@ export function BlockerDialog({
</div>
<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 && (
<div className="p-2 bg-red-100 text-red-700 rounded text-sm mb-4">
@@ -114,23 +116,23 @@ export function BlockerDialog({
{activeBlocker && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<h3 className="font-semibold text-red-800 mb-2">
Active Blocker
{t('blockers.activeBlocker')}
</h3>
<p className="text-gray-700 mb-2">{activeBlocker.reason}</p>
<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()}
</p>
{/* Resolve form */}
<form onSubmit={handleResolveBlocker} className="mt-4">
<label className="block text-sm font-medium mb-1">
Resolution Note
{t('blockers.resolutionNote')}
</label>
<textarea
value={resolutionNote}
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"
rows={3}
required
@@ -140,7 +142,7 @@ export function BlockerDialog({
disabled={submitting || !resolutionNote.trim()}
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>
</form>
</div>
@@ -150,12 +152,12 @@ export function BlockerDialog({
{!activeBlocker && (
<form onSubmit={handleCreateBlocker} className="mb-4">
<label className="block text-sm font-medium mb-1">
Blocker Reason
{t('blockers.blockerReason')}
</label>
<textarea
value={reason}
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"
rows={3}
required
@@ -165,7 +167,7 @@ export function BlockerDialog({
disabled={submitting || !reason.trim()}
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>
</form>
)}
@@ -173,7 +175,7 @@ export function BlockerDialog({
{/* History */}
{blockers.filter(b => b.resolved_at).length > 0 && (
<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">
{blockers
.filter(b => b.resolved_at)
@@ -184,12 +186,12 @@ export function BlockerDialog({
>
<p className="font-medium">{blocker.reason}</p>
<p className="text-gray-500 mt-1">
Reported: {blocker.reporter.name} {' '}
{t('blockers.reported')}: {blocker.reporter.name} {' '}
{new Date(blocker.created_at).toLocaleDateString()}
</p>
{blocker.resolver && (
<p className="text-green-600 mt-1">
Resolved: {blocker.resolver.name} {' '}
{t('blockers.resolved')}: {blocker.resolver.name} {' '}
{new Date(blocker.resolved_at!).toLocaleDateString()}
</p>
)}
@@ -212,7 +214,7 @@ export function BlockerDialog({
onClick={onClose}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
Close
{t('common:buttons.close')}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import FullCalendar from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
@@ -75,6 +76,7 @@ export function CalendarView({
onTaskClick,
onTaskUpdate,
}: CalendarViewProps) {
const { t } = useTranslation('tasks')
const calendarRef = useRef<FullCalendar>(null)
const [events, setEvents] = useState<CalendarEvent[]>([])
const [loading, setLoading] = useState(false)
@@ -306,13 +308,13 @@ export function CalendarView({
{/* Filter Controls */}
<div style={styles.filterBar}>
<div style={styles.filterGroup}>
<label style={styles.filterLabel}>Assignee</label>
<label style={styles.filterLabel}>{t('fields.assignee')}</label>
<select
value={filterAssignee}
onChange={(e) => setFilterAssignee(e.target.value)}
style={styles.filterSelect}
>
<option value="">All Assignees</option>
<option value="">{t('calendar.allAssignees')}</option>
{assignees.map((assignee) => (
<option key={assignee.id} value={assignee.id}>
{assignee.name}
@@ -322,36 +324,36 @@ export function CalendarView({
</div>
<div style={styles.filterGroup}>
<label style={styles.filterLabel}>Status</label>
<label style={styles.filterLabel}>{t('fields.status')}</label>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
style={styles.filterSelect}
>
<option value="all">All Tasks</option>
<option value="active">Active Only</option>
<option value="completed">Completed Only</option>
<option value="all">{t('filters.all')}</option>
<option value="active">{t('calendar.activeOnly')}</option>
<option value="completed">{t('calendar.completedOnly')}</option>
</select>
</div>
<div style={styles.filterGroup}>
<label style={styles.filterLabel}>Priority</label>
<label style={styles.filterLabel}>{t('fields.priority')}</label>
<select
value={filterPriority}
onChange={(e) => setFilterPriority(e.target.value)}
style={styles.filterSelect}
>
<option value="">All Priorities</option>
<option value="urgent">Urgent</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
<option value="">{t('calendar.allPriorities')}</option>
<option value="urgent">{t('priority.urgent')}</option>
<option value="high">{t('priority.high')}</option>
<option value="medium">{t('priority.medium')}</option>
<option value="low">{t('priority.low')}</option>
</select>
</div>
{hasActiveFilters && (
<button onClick={handleClearFilters} style={styles.clearFiltersButton}>
Clear Filters
{t('calendar.clearFilters')}
</button>
)}
@@ -392,17 +394,17 @@ export function CalendarView({
<div style={styles.legend}>
<div style={styles.legendItem}>
<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 style={styles.legendItem}>
<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 style={styles.legendItem}>
<span style={styles.legendText}>Priority:</span>
<span style={{ ...styles.priorityLabel, color: priorityColors.urgent }}>!!! Urgent</span>
<span style={{ ...styles.priorityLabel, color: priorityColors.high }}>!! High</span>
<span style={{ ...styles.priorityLabel, color: priorityColors.medium }}>! Medium</span>
<span style={styles.legendText}>{t('fields.priority')}:</span>
<span style={{ ...styles.priorityLabel, color: priorityColors.urgent }}>!!! {t('priority.urgent')}</span>
<span style={{ ...styles.priorityLabel, color: priorityColors.high }}>!! {t('priority.high')}</span>
<span style={{ ...styles.priorityLabel, color: priorityColors.medium }}>! {t('priority.medium')}</span>
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { commentsApi, usersApi, Comment, UserSearchResult } from '../services/collaboration'
import { useAuth } from '../contexts/AuthContext'
import { ConfirmModal } from './ConfirmModal'
@@ -9,6 +10,7 @@ interface CommentsProps {
}
export function Comments({ taskId }: CommentsProps) {
const { t } = useTranslation('tasks')
const { user } = useAuth()
const [comments, setComments] = useState<Comment[]>([])
const [loading, setLoading] = useState(true)
@@ -29,7 +31,7 @@ export function Comments({ taskId }: CommentsProps) {
setComments(response.comments)
setError(null)
} catch (err) {
setError('Failed to load comments')
setError(t('comments.error.load'))
} finally {
setLoading(false)
}
@@ -55,7 +57,7 @@ export function Comments({ taskId }: CommentsProps) {
setNewComment('')
setReplyTo(null)
} catch (err) {
setError('Failed to post comment')
setError(t('comments.error.post'))
} finally {
setSubmitting(false)
}
@@ -69,7 +71,7 @@ export function Comments({ taskId }: CommentsProps) {
setEditingId(null)
setEditContent('')
} catch (err) {
setError('Failed to update comment')
setError(t('comments.error.update'))
}
}
@@ -79,7 +81,7 @@ export function Comments({ taskId }: CommentsProps) {
await fetchComments()
setDeleteConfirm(null)
} catch (err) {
setError('Failed to delete comment')
setError(t('comments.error.delete'))
}
}
@@ -119,7 +121,7 @@ export function Comments({ taskId }: CommentsProps) {
return (
<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 && (
<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()}
</span>
{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>
{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"
>
Edit
{t('common:buttons.edit')}
</button>
<button
onClick={() => setDeleteConfirm(comment.id)}
className="text-sm text-red-600 hover:underline"
>
Delete
{t('common:buttons.delete')}
</button>
</div>
)}
@@ -173,13 +175,13 @@ export function Comments({ taskId }: CommentsProps) {
onClick={() => handleEdit(comment.id)}
className="px-3 py-1 bg-blue-600 text-white rounded text-sm"
>
Save
{t('common:buttons.save')}
</button>
<button
onClick={() => setEditingId(null)}
className="px-3 py-1 bg-gray-300 rounded text-sm"
>
Cancel
{t('common:buttons.cancel')}
</button>
</div>
</div>
@@ -192,7 +194,7 @@ export function Comments({ taskId }: CommentsProps) {
{/* Mentions */}
{comment.mentions.length > 0 && (
<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>
)}
@@ -202,7 +204,7 @@ export function Comments({ taskId }: CommentsProps) {
onClick={() => setReplyTo(comment.id)}
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>
)}
</div>
@@ -213,13 +215,13 @@ export function Comments({ taskId }: CommentsProps) {
<form onSubmit={handleSubmit} className="space-y-2">
{replyTo && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<span>Replying to comment</span>
<span>{t('comments.replyingTo')}</span>
<button
type="button"
onClick={() => setReplyTo(null)}
className="text-red-600 hover:underline"
>
Cancel
{t('common:buttons.cancel')}
</button>
</div>
)}
@@ -227,7 +229,7 @@ export function Comments({ taskId }: CommentsProps) {
<textarea
value={newComment}
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"
rows={3}
/>
@@ -253,16 +255,16 @@ export function Comments({ taskId }: CommentsProps) {
disabled={!newComment.trim() || submitting}
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>
</form>
<ConfirmModal
isOpen={deleteConfirm !== null}
title="Delete Comment"
message="Are you sure you want to delete this comment? This action cannot be undone."
confirmText="Delete"
cancelText="Cancel"
title={t('comments.delete')}
message={t('comments.confirmDelete')}
confirmText={t('common:buttons.delete')}
cancelText={t('common:buttons.cancel')}
confirmStyle="danger"
onConfirm={() => deleteConfirm && handleDelete(deleteConfirm)}
onCancel={() => setDeleteConfirm(null)}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CustomValueResponse } from '../services/customFields'
interface Task {
@@ -39,6 +40,7 @@ export function KanbanBoard({
onStatusChange,
onTaskClick,
}: KanbanBoardProps) {
const { t } = useTranslation('tasks')
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null)
const [dragOverColumnId, setDragOverColumnId] = useState<string | null>(null)
@@ -135,7 +137,7 @@ export function KanbanBoard({
</span>
)}
{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) */}
{task.custom_values?.slice(0, 2).map((cv) => (
@@ -158,7 +160,7 @@ export function KanbanBoard({
backgroundColor: '#9e9e9e',
}}
>
<span style={styles.columnTitle}>No Status</span>
<span style={styles.columnTitle}>{t('status.noStatus')}</span>
<span style={styles.taskCount}>{unassignedTasks.length}</span>
</div>
<div style={styles.taskList}>
@@ -194,7 +196,7 @@ export function KanbanBoard({
{tasksByStatus[status.id]?.map(renderTaskCard)}
{(!tasksByStatus[status.id] || tasksByStatus[status.id].length === 0) && (
<div style={styles.emptyColumn}>
Drop tasks here
{t('kanban.dropHere')}
</div>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import api from '../services/api'
interface Subtask {
@@ -25,6 +26,7 @@ export function SubtaskList({
onSubtaskClick,
onSubtaskCreated,
}: SubtaskListProps) {
const { t } = useTranslation('tasks')
const [subtasks, setSubtasks] = useState<Subtask[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -41,7 +43,7 @@ export function SubtaskList({
setError(null)
} catch (err) {
console.error('Failed to fetch subtasks:', err)
setError('Failed to load subtasks')
setError(t('subtasks.error.load'))
} finally {
setLoading(false)
}
@@ -69,7 +71,7 @@ export function SubtaskList({
} catch (err: unknown) {
console.error('Failed to create subtask:', err)
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)
} finally {
setSubmitting(false)
@@ -99,7 +101,7 @@ export function SubtaskList({
role="button"
tabIndex={0}
aria-expanded={expanded}
aria-label={`Subtasks section, ${subtasks.length} items`}
aria-label={`${t('subtasks.title')}, ${subtasks.length}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
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>
</div>
{expanded && (
<div style={styles.content}>
{loading ? (
<div style={styles.loadingText}>Loading subtasks...</div>
<div style={styles.loadingText}>{t('common:labels.loading')}</div>
) : error ? (
<div style={styles.errorText}>{error}</div>
) : (
@@ -165,21 +167,21 @@ export function SubtaskList({
))}
</div>
) : (
<div style={styles.emptyText}>No subtasks yet</div>
<div style={styles.emptyText}>{t('subtasks.empty')}</div>
)}
{/* Add Subtask Form */}
{showAddForm ? (
<form onSubmit={handleAddSubtask} style={styles.addForm}>
<label htmlFor="new-subtask-title" style={styles.visuallyHidden}>
Subtask title
{t('subtasks.placeholder')}
</label>
<input
id="new-subtask-title"
type="text"
value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)}
placeholder="Enter subtask title..."
placeholder={t('subtasks.placeholder')}
style={styles.input}
autoFocus
disabled={submitting}
@@ -191,14 +193,14 @@ export function SubtaskList({
style={styles.cancelButton}
disabled={submitting}
>
Cancel
{t('common:buttons.cancel')}
</button>
<button
type="submit"
style={styles.submitButton}
disabled={!newSubtaskTitle.trim() || submitting}
>
{submitting ? 'Adding...' : 'Add'}
{submitting ? t('subtasks.adding') : t('common:buttons.add')}
</button>
</div>
</form>
@@ -207,7 +209,7 @@ export function SubtaskList({
onClick={() => setShowAddForm(true)}
style={styles.addButton}
>
+ Add Subtask
+ {t('subtasks.add')}
</button>
)}
</>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import api from '../services/api'
import { Comments } from './Comments'
import { TaskAttachments } from './TaskAttachments'
@@ -50,6 +51,7 @@ export function TaskDetailModal({
onUpdate,
onSubtaskClick,
}: TaskDetailModalProps) {
const { t } = useTranslation('tasks')
const [isEditing, setIsEditing] = useState(false)
const [saving, setSaving] = useState(false)
const [editForm, setEditForm] = useState({
@@ -263,7 +265,7 @@ export function TaskDetailModal({
<div style={styles.headerActions}>
{!isEditing ? (
<button onClick={() => setIsEditing(true)} style={styles.editButton}>
Edit
{t('common:buttons.edit')}
</button>
) : (
<>
@@ -272,18 +274,18 @@ export function TaskDetailModal({
style={styles.cancelButton}
disabled={saving}
>
Cancel
{t('common:buttons.cancel')}
</button>
<button
onClick={handleSave}
style={styles.saveButton}
disabled={saving || !editForm.title.trim()}
>
{saving ? 'Saving...' : 'Save'}
{saving ? t('common:labels.loading') : t('common:buttons.save')}
</button>
</>
)}
<button onClick={onClose} style={styles.closeButton} aria-label="Close">
<button onClick={onClose} style={styles.closeButton} aria-label={t('common:buttons.close')}>
X
</button>
</div>
@@ -293,7 +295,7 @@ export function TaskDetailModal({
<div style={styles.mainSection}>
{/* Description */}
<div style={styles.field}>
<label style={styles.fieldLabel}>Description</label>
<label style={styles.fieldLabel}>{t('fields.description')}</label>
{isEditing ? (
<textarea
value={editForm.description}
@@ -301,11 +303,11 @@ export function TaskDetailModal({
setEditForm({ ...editForm, description: e.target.value })
}
style={styles.textarea}
placeholder="Add a description..."
placeholder={t('fields.descriptionPlaceholder')}
/>
) : (
<div style={styles.descriptionText}>
{task.description || 'No description'}
{task.description || t('common:labels.noData')}
</div>
)}
</div>
@@ -334,7 +336,7 @@ export function TaskDetailModal({
<div style={styles.sidebar}>
{/* Status */}
<div style={styles.sidebarField}>
<label style={styles.sidebarLabel}>Status</label>
<label style={styles.sidebarLabel}>{t('fields.status')}</label>
{isEditing ? (
<select
value={editForm.status_id}
@@ -343,7 +345,7 @@ export function TaskDetailModal({
}
style={styles.select}
>
<option value="">No Status</option>
<option value="">{t('status.noStatus')}</option>
{statuses.map((status) => (
<option key={status.id} value={status.id}>
{status.name}
@@ -357,14 +359,14 @@ export function TaskDetailModal({
backgroundColor: task.status_color || '#e0e0e0',
}}
>
{task.status_name || 'No Status'}
{task.status_name || t('status.noStatus')}
</div>
)}
</div>
{/* Priority */}
<div style={styles.sidebarField}>
<label style={styles.sidebarLabel}>Priority</label>
<label style={styles.sidebarLabel}>{t('fields.priority')}</label>
{isEditing ? (
<select
value={editForm.priority}
@@ -373,10 +375,10 @@ export function TaskDetailModal({
}
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>
) : (
<div
@@ -386,30 +388,30 @@ export function TaskDetailModal({
color: getPriorityColor(task.priority),
}}
>
{task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}
{t(`priority.${task.priority}`)}
</div>
)}
</div>
{/* Assignee */}
<div style={styles.sidebarField}>
<label style={styles.sidebarLabel}>Assignee</label>
<label style={styles.sidebarLabel}>{t('fields.assignee')}</label>
{isEditing ? (
<UserSelect
value={editForm.assignee_id}
onChange={handleAssigneeChange}
placeholder="Select assignee..."
placeholder={t('common:labels.selectAssignee')}
/>
) : (
<div style={styles.assigneeDisplay}>
{task.assignee_name || 'Unassigned'}
{task.assignee_name || t('status.unassigned')}
</div>
)}
</div>
{/* Due Date */}
<div style={styles.sidebarField}>
<label style={styles.sidebarLabel}>Due Date</label>
<label style={styles.sidebarLabel}>{t('fields.dueDate')}</label>
{isEditing ? (
<input
type="date"
@@ -423,14 +425,14 @@ export function TaskDetailModal({
<div style={styles.dueDateDisplay}>
{task.due_date
? new Date(task.due_date).toLocaleDateString()
: 'No due date'}
: t('status.noDueDate')}
</div>
)}
</div>
{/* Time Estimate */}
<div style={styles.sidebarField}>
<label style={styles.sidebarLabel}>Time Estimate (hours)</label>
<label style={styles.sidebarLabel}>{t('fields.estimatedHours')}</label>
{isEditing ? (
<input
type="number"
@@ -445,7 +447,7 @@ export function TaskDetailModal({
/>
) : (
<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>
@@ -453,8 +455,8 @@ export function TaskDetailModal({
{/* Subtasks Info */}
{task.subtask_count > 0 && (
<div style={styles.sidebarField}>
<label style={styles.sidebarLabel}>Subtasks</label>
<div style={styles.subtaskInfo}>{task.subtask_count} subtask(s)</div>
<label style={styles.sidebarLabel}>{t('subtasks.title')}</label>
<div style={styles.subtaskInfo}>{t('subtasks.count', { count: task.subtask_count })}</div>
</div>
)}
@@ -462,7 +464,7 @@ export function TaskDetailModal({
{customFields.length > 0 && (
<>
<div style={styles.customFieldsDivider} />
<div style={styles.customFieldsHeader}>Custom Fields</div>
<div style={styles.customFieldsHeader}>{t('settings:customFields.title')}</div>
{loadingCustomFields ? (
<SkeletonList count={3} showAvatar={false} />
) : (

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { ProjectHealthCard } from '../components/ProjectHealthCard'
import { SkeletonGrid } from '../components/Skeleton'
import {
@@ -11,14 +12,6 @@ import {
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)
const riskLevelPriority: Record<RiskLevel, number> = {
low: 1,
@@ -28,6 +21,7 @@ const riskLevelPriority: Record<RiskLevel, number> = {
}
export default function ProjectHealthPage() {
const { t } = useTranslation('health')
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -42,7 +36,7 @@ export default function ProjectHealthPage() {
setDashboardData(data)
} catch (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 {
setLoading(false)
}
@@ -99,9 +93,9 @@ export default function ProjectHealthPage() {
{/* Header */}
<div style={styles.header}>
<div>
<h1 style={styles.title}>Project Health Dashboard</h1>
<h1 style={styles.title}>{t('title')}</h1>
<p style={styles.subtitle}>
Monitor project health status and risk levels across all projects
{t('subtitle')}
</p>
</div>
</div>
@@ -111,25 +105,25 @@ export default function ProjectHealthPage() {
<div style={styles.summaryContainer}>
<div style={styles.summaryCard}>
<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 style={styles.summaryCard}>
<span style={{ ...styles.summaryValue, color: '#4caf50' }}>
{dashboardData.summary.healthy_count}
</span>
<span style={styles.summaryLabel}>Healthy</span>
<span style={styles.summaryLabel}>{t('summary.healthy')}</span>
</div>
<div style={styles.summaryCard}>
<span style={{ ...styles.summaryValue, color: '#ff9800' }}>
{dashboardData.summary.at_risk_count}
</span>
<span style={styles.summaryLabel}>At Risk</span>
<span style={styles.summaryLabel}>{t('summary.atRisk')}</span>
</div>
<div style={styles.summaryCard}>
<span style={{ ...styles.summaryValue, color: '#f44336' }}>
{dashboardData.summary.critical_count}
</span>
<span style={styles.summaryLabel}>Critical</span>
<span style={styles.summaryLabel}>{t('summary.critical')}</span>
</div>
<div style={styles.summaryCard}>
<span
@@ -140,19 +134,19 @@ export default function ProjectHealthPage() {
>
{Math.round(dashboardData.summary.average_health_score)}
</span>
<span style={styles.summaryLabel}>Avg. Health</span>
<span style={styles.summaryLabel}>{t('summary.avgHealth')}</span>
</div>
<div style={styles.summaryCard}>
<span style={{ ...styles.summaryValue, color: dashboardData.summary.projects_with_blockers > 0 ? '#f44336' : '#666' }}>
{dashboardData.summary.projects_with_blockers}
</span>
<span style={styles.summaryLabel}>With Blockers</span>
<span style={styles.summaryLabel}>{t('summary.withBlockers')}</span>
</div>
<div style={styles.summaryCard}>
<span style={{ ...styles.summaryValue, color: dashboardData.summary.projects_delayed > 0 ? '#ff9800' : '#666' }}>
{dashboardData.summary.projects_delayed}
</span>
<span style={styles.summaryLabel}>Delayed</span>
<span style={styles.summaryLabel}>{t('summary.delayed')}</span>
</div>
</div>
)}
@@ -162,7 +156,7 @@ export default function ProjectHealthPage() {
<div style={styles.controlsContainer}>
<div style={styles.sortControl}>
<label htmlFor="sort-select" style={styles.sortLabel}>
Sort by:
{t('sort.label')}:
</label>
<select
id="sort-select"
@@ -170,15 +164,15 @@ export default function ProjectHealthPage() {
onChange={handleSortChange}
style={styles.sortSelect}
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
<option value="risk_high">{t('sort.riskHigh')}</option>
<option value="risk_low">{t('sort.riskLow')}</option>
<option value="health_high">{t('sort.healthHigh')}</option>
<option value="health_low">{t('sort.healthLow')}</option>
<option value="name">{t('sort.name')}</option>
</select>
</div>
<span style={styles.projectCount}>
{dashboardData.projects.length} project{dashboardData.projects.length !== 1 ? 's' : ''}
{t('projectCount', { count: dashboardData.projects.length })}
</span>
</div>
)}
@@ -190,14 +184,14 @@ export default function ProjectHealthPage() {
<div style={styles.errorContainer}>
<p style={styles.error}>{error}</p>
<button onClick={loadDashboard} style={styles.retryButton}>
Retry
{t('actions.retry')}
</button>
</div>
) : sortedProjects.length === 0 ? (
<div style={styles.emptyContainer}>
<p style={styles.emptyText}>No projects found.</p>
<p style={styles.emptyText}>{t('empty.title')}</p>
<p style={styles.emptySubtext}>
Create a project to start tracking health status.
{t('empty.description')}
</p>
</div>
) : (

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import api from '../services/api'
import { CustomFieldList } from '../components/CustomFieldList'
import { useToast } from '../contexts/ToastContext'
@@ -15,6 +16,7 @@ interface Project {
}
export default function ProjectSettings() {
const { t } = useTranslation('settings')
const { projectId } = useParams()
const navigate = useNavigate()
const { showToast } = useToast()
@@ -32,7 +34,7 @@ export default function ProjectSettings() {
setProject(response.data)
} catch (err) {
console.error('Failed to load project:', err)
showToast('Failed to load project settings', 'error')
showToast(t('common:messages.error'), 'error')
} finally {
setLoading(false)
}
@@ -59,21 +61,21 @@ export default function ProjectSettings() {
}
if (!project) {
return <div style={styles.error}>Project not found</div>
return <div style={styles.error}>{t('common:messages.notFound')}</div>
}
return (
<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
@@ -83,16 +85,16 @@ export default function ProjectSettings() {
{project.title}
</span>
<span style={styles.breadcrumbSeparator}>/</span>
<span>Settings</span>
<span>{t('title')}</span>
</div>
<div style={styles.header}>
<h1 style={styles.title}>Project Settings</h1>
<h1 style={styles.title}>{t('projectSettings')}</h1>
<button
onClick={() => navigate(`/projects/${project.id}`)}
style={styles.backButton}
>
Back to Tasks
{t('backToTasks')}
</button>
</div>
@@ -107,7 +109,7 @@ export default function ProjectSettings() {
...(activeTab === 'general' ? styles.navItemActive : {}),
}}
>
General
{t('tabs.general')}
</button>
<button
onClick={() => setActiveTab('custom-fields')}
@@ -116,7 +118,7 @@ export default function ProjectSettings() {
...(activeTab === 'custom-fields' ? styles.navItemActive : {}),
}}
>
Custom Fields
{t('tabs.customFields')}
</button>
</nav>
</div>
@@ -125,25 +127,25 @@ export default function ProjectSettings() {
<div style={styles.content}>
{activeTab === 'general' && (
<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.infoRow}>
<span style={styles.infoLabel}>Project Name</span>
<span style={styles.infoLabel}>{t('general.projectName')}</span>
<span style={styles.infoValue}>{project.title}</span>
</div>
<div style={styles.infoRow}>
<span style={styles.infoLabel}>Description</span>
<span style={styles.infoLabel}>{t('general.description')}</span>
<span style={styles.infoValue}>
{project.description || 'No description'}
{project.description || t('general.noDescription')}
</span>
</div>
<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>
</div>
</div>
<p style={styles.helpText}>
To edit project details, contact the project owner.
{t('general.helpText')}
</p>
</div>
)}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import api from '../services/api'
import { SkeletonGrid } from '../components/Skeleton'
import { useToast } from '../contexts/ToastContext'
@@ -23,6 +24,7 @@ interface Space {
}
export default function Projects() {
const { t } = useTranslation('projects')
const { spaceId } = useParams()
const navigate = useNavigate()
const { showToast } = useToast()
@@ -71,7 +73,7 @@ export default function Projects() {
setProjects(projectsRes.data)
} catch (err) {
console.error('Failed to load data:', err)
showToast('Failed to load projects', 'error')
showToast(t('messages.loadFailed'), 'error')
} finally {
setLoading(false)
}
@@ -86,10 +88,10 @@ export default function Projects() {
setShowCreateModal(false)
setNewProject({ title: '', description: '', security_level: 'department' })
loadData()
showToast('Project created successfully', 'success')
showToast(t('messages.created'), 'success')
} catch (err) {
console.error('Failed to create project:', err)
showToast('Failed to create project', 'error')
showToast(t('messages.createFailed'), 'error')
} finally {
setCreating(false)
}
@@ -125,16 +127,16 @@ export default function Projects() {
<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>{space?.name}</span>
</div>
<div style={styles.header}>
<h1 style={styles.title}>Projects</h1>
<h1 style={styles.title}>{t('title')}</h1>
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
+ New Project
+ {t('newProject')}
</button>
</div>
@@ -152,7 +154,7 @@ export default function Projects() {
}}
role="button"
tabIndex={0}
aria-label={`Open project: ${project.title}`}
aria-label={`${t('title')}: ${project.title}`}
>
<div style={styles.cardHeader}>
<h3 style={styles.cardTitle}>{project.title}</h3>
@@ -161,18 +163,18 @@ export default function Projects() {
</span>
</div>
<p style={styles.cardDescription}>
{project.description || 'No description'}
{project.description || t('card.noDescription')}
</p>
<div style={styles.cardMeta}>
<span>{project.task_count} tasks</span>
<span>Owner: {project.owner_name || 'Unknown'}</span>
<span>{t('card.tasks', { count: project.task_count })}</span>
<span>{t('card.owner')}: {project.owner_name || t('card.unknown')}</span>
</div>
</div>
))}
{projects.length === 0 && (
<div style={styles.empty}>
<p>No projects yet. Create your first project!</p>
<p>{t('empty.description')}</p>
</div>
)}
</div>
@@ -187,49 +189,49 @@ export default function Projects() {
aria-labelledby="create-project-title"
>
<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}>
Project title
{t('fields.title')}
</label>
<input
id="project-title"
type="text"
placeholder="Project title"
placeholder={t('fields.titlePlaceholder')}
value={newProject.title}
onChange={(e) => setNewProject({ ...newProject, title: e.target.value })}
style={styles.input}
autoFocus
/>
<label htmlFor="project-description" style={styles.visuallyHidden}>
Description
{t('fields.description')}
</label>
<textarea
id="project-description"
placeholder="Description (optional)"
placeholder={t('fields.descriptionPlaceholder')}
value={newProject.description}
onChange={(e) => setNewProject({ ...newProject, description: e.target.value })}
style={styles.textarea}
/>
<label style={styles.label}>Security Level</label>
<label style={styles.label}>{t('securityLevel.label')}</label>
<select
value={newProject.security_level}
onChange={(e) => setNewProject({ ...newProject, security_level: e.target.value })}
style={styles.select}
>
<option value="public">Public - All users</option>
<option value="department">Department - Same department only</option>
<option value="confidential">Confidential - Owner only</option>
<option value="public">{t('securityLevel.public')}</option>
<option value="department">{t('securityLevel.department')}</option>
<option value="confidential">{t('securityLevel.confidential')}</option>
</select>
<div style={styles.modalActions}>
<button onClick={() => setShowCreateModal(false)} style={styles.cancelButton}>
Cancel
{t('common:buttons.cancel')}
</button>
<button
onClick={handleCreateProject}
disabled={creating || !newProject.title.trim()}
style={styles.submitButton}
>
{creating ? 'Creating...' : 'Create'}
{creating ? t('common:labels.loading') : t('common:buttons.create')}
</button>
</div>
</div>

View File

@@ -322,7 +322,7 @@ export default function Tasks() {
payload.due_date = newTask.due_date
}
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)