feat: implement soft delete, task editing fixes, and UI improvements

Backend:
- Add soft delete for spaces and projects (is_active flag)
- Add status_id and assignee_id to TaskUpdate schema
- Fix task PATCH endpoint to update status and assignee
- Add validation for assignee_id and status_id in task updates
- Fix health service to count tasks with "Blocked" status as blockers
- Filter out deleted spaces/projects from health dashboard
- Add workload cache invalidation on assignee changes

Frontend:
- Add delete confirmation dialogs for spaces and projects
- Fix UserSelect to display selected user name (valueName prop)
- Fix task detail modal to refresh data after save
- Enforce 2-level subtask depth limit in UI
- Fix timezone bug in date formatting (use local timezone)
- Convert NotificationBell from Tailwind to inline styles
- Add i18n translations for health, workload, settings pages
- Add parent_task_id to Task interface across components

OpenSpec:
- Archive add-delete-capability change

🤖 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-10 01:32:13 +08:00
parent 2796cbb42d
commit 55f85d0d3c
44 changed files with 1854 additions and 297 deletions

View File

@@ -92,7 +92,15 @@
"title": "Notifications",
"markAllRead": "Mark all as read",
"noNotifications": "No notifications",
"viewAll": "View all"
"empty": "No notifications",
"viewAll": "View all",
"refresh": "Refresh",
"time": {
"justNow": "Just now",
"minutesAgo": "{{count}}m ago",
"hoursAgo": "{{count}}h ago",
"daysAgo": "{{count}}d ago"
}
},
"pagination": {
"page": "Page {{page}}",

View File

@@ -11,11 +11,24 @@
"totalProjects": "Total Projects",
"healthy": "Healthy",
"atRisk": "At Risk",
"highRisk": "High Risk",
"critical": "Critical",
"avgHealth": "Avg. Health",
"withBlockers": "With Blockers",
"delayed": "Delayed"
},
"calculation": {
"title": "Health Score Calculation",
"formula": "Starting score: 100, reduced by:",
"blockers": "Blockers: -10 per item (max -30)",
"overdue": "Overdue tasks: -5 per item (max -30)",
"completion": "Low completion: up to -20 if below 50%",
"thresholds": "Risk Level Thresholds:",
"lowRisk": "Low Risk (Healthy): ≥ 80",
"mediumRisk": "Medium Risk: 60-79",
"highRiskLevel": "High Risk: 40-59",
"criticalRisk": "Critical: < 40"
},
"sort": {
"label": "Sort by",
"riskHigh": "Risk: High to Low",
@@ -36,7 +49,29 @@
"delayed": "Delayed",
"ahead": "Ahead",
"overBudget": "Over Budget",
"underBudget": "Under Budget"
"underBudget": "Under Budget",
"atRisk": "At Risk"
},
"resourceStatus": {
"adequate": "Adequate",
"constrained": "Constrained",
"overloaded": "Overloaded"
},
"card": {
"health": "Health",
"schedule": "Schedule",
"resources": "Resources",
"owner": "Owner",
"taskProgress": "Task Progress",
"blockers": "Blockers",
"overdue": "Overdue",
"complete": "Complete"
},
"riskLevel": {
"low": "Low Risk",
"medium": "Medium Risk",
"high": "High Risk",
"critical": "Critical"
},
"indicators": {
"title": "Health Indicators",

View File

@@ -2,6 +2,21 @@
"title": "Settings",
"projectSettings": "Project Settings",
"backToTasks": "Back to Tasks",
"mySettings": {
"title": "My Settings",
"profile": "Profile",
"email": "Email",
"department": "Department",
"role": "Role",
"workloadSettings": "Workload Settings",
"capacityDescription": "Set your weekly available work hours, used to calculate workload percentage.",
"weeklyCapacity": "Weekly Capacity",
"hoursPerWeek": "hours/week",
"capacityHelp": "Recommended: 40 hours (standard work week). Maximum: 168 hours (total hours in a week).",
"capacitySaved": "Capacity settings saved",
"capacityError": "Failed to save capacity settings",
"capacityInvalid": "Please enter a valid number of hours (0-168)"
},
"tabs": {
"general": "General",
"members": "Members",
@@ -37,16 +52,58 @@
"add": "Add Field",
"edit": "Edit Field",
"delete": "Delete Field",
"create": "Create Field",
"fieldName": "Field Name",
"fieldNamePlaceholder": "e.g., Story Points, Sprint Number",
"fieldType": "Field Type",
"required": "Required",
"requiredField": "Required field",
"requiredHelp": "Tasks cannot be created or updated without filling in required fields.",
"cannotChangeType": "cannot be changed",
"description": "Custom fields allow you to add additional data to tasks. You can create up to 20 fields per project.",
"loading": "Loading custom fields...",
"loadError": "Failed to load custom fields",
"retry": "Retry",
"empty": "No custom fields defined yet.",
"emptyHint": "Click \"Add Field\" to create your first custom field.",
"deleteConfirmTitle": "Delete Custom Field?",
"deleteConfirmMessage": "This will permanently delete this field and all stored values for all tasks. This action cannot be undone.",
"deleting": "Deleting...",
"deleted": "Custom field deleted successfully",
"deleteError": "Failed to delete field",
"saving": "Saving...",
"saveChanges": "Save Changes",
"saveError": "Failed to save field",
"options": "Options",
"optionPlaceholder": "Option {{index}}",
"addOption": "Add Option",
"optionRequired": "At least one option is required for dropdown fields",
"formula": "Formula Expression",
"formulaPlaceholder": "e.g., {time_spent} / {original_estimate} * 100",
"formulaRequired": "Formula expression is required",
"formulaHelp": {
"intro": "Use curly braces to reference other fields:",
"customField": "Reference a custom number field",
"estimate": "Task time estimate",
"timeSpent": "Logged time",
"operators": "Supported operators: +, -, *, /"
},
"types": {
"text": "Text",
"textDesc": "Single line text input",
"number": "Number",
"numberDesc": "Numeric value",
"date": "Date",
"select": "Dropdown",
"multiSelect": "Multi-select",
"checkbox": "Checkbox"
"dateDesc": "Date picker",
"dropdown": "Dropdown",
"dropdownDesc": "Select from predefined options",
"person": "Person",
"personDesc": "User assignment",
"formula": "Formula",
"formulaDesc": "Calculated from other fields"
},
"validation": {
"nameRequired": "Field name is required"
}
},
"notifications": {

View File

@@ -30,13 +30,43 @@
"overloaded": "Overloaded",
"underutilized": "Underutilized"
},
"table": {
"member": "Team Member",
"department": "Department",
"allocated": "Allocated",
"capacity": "Capacity",
"load": "Load",
"status": "Status"
},
"status": {
"balanced": "Balanced",
"normal": "Normal",
"warning": "Warning",
"overloaded": "Overloaded",
"unavailable": "Unavailable",
"underutilized": "Underutilized"
},
"empty": {
"title": "No Workload Data",
"description": "Not enough data to display workload"
"description": "Not enough data to display workload",
"noTasks": "No one has been assigned tasks this week",
"hint": "Team members will appear here when they are assigned tasks with due dates in this week."
},
"options": {
"showAllUsers": "Show all users",
"showAllUsersHint": "(including users without tasks)"
},
"calculation": {
"title": "Workload Calculation",
"formula": "Workload = Weekly task estimated hours ÷ Personal weekly capacity × 100%",
"requirements": "Tasks must meet all conditions to be counted:",
"req1": "Task is assigned to the member",
"req2": "Task due date falls within the selected week",
"req3": "Task has estimated hours (original_estimate)",
"req4": "Task is not completed",
"thresholds": "Load Level Thresholds:",
"normal": "Normal: < 80%",
"warning": "Warning: 80% - 99%",
"overloaded": "Overloaded: ≥ 100%"
}
}

View File

@@ -92,7 +92,15 @@
"title": "通知",
"markAllRead": "全部標為已讀",
"noNotifications": "沒有通知",
"viewAll": "查看全部"
"empty": "沒有通知",
"viewAll": "查看全部",
"refresh": "重新整理",
"time": {
"justNow": "剛剛",
"minutesAgo": "{{count}} 分鐘前",
"hoursAgo": "{{count}} 小時前",
"daysAgo": "{{count}} 天前"
}
},
"pagination": {
"page": "第 {{page}} 頁",

View File

@@ -11,11 +11,24 @@
"totalProjects": "專案總數",
"healthy": "健康",
"atRisk": "風險中",
"highRisk": "高風險",
"critical": "危急",
"avgHealth": "平均健康度",
"withBlockers": "有阻擋問題",
"delayed": "延遲"
},
"calculation": {
"title": "健康度計算方式",
"formula": "起始分數 100 分,依據以下因素扣分:",
"blockers": "阻擋問題:每項 -10 分(最多 -30 分)",
"overdue": "逾期任務:每項 -5 分(最多 -30 分)",
"completion": "完成度不足:若低於 50%,最多 -20 分",
"thresholds": "風險等級閾值:",
"lowRisk": "低風險(健康):≥ 80 分",
"mediumRisk": "中風險60-79 分",
"highRiskLevel": "高風險40-59 分",
"criticalRisk": "危急:< 40 分"
},
"sort": {
"label": "排序方式",
"riskHigh": "風險:高到低",
@@ -36,7 +49,29 @@
"delayed": "延遲",
"ahead": "超前",
"overBudget": "超支",
"underBudget": "低於預算"
"underBudget": "低於預算",
"atRisk": "有風險"
},
"resourceStatus": {
"adequate": "充足",
"constrained": "受限",
"overloaded": "超載"
},
"card": {
"health": "健康度",
"schedule": "進度",
"resources": "資源",
"owner": "負責人",
"taskProgress": "任務進度",
"blockers": "阻擋問題",
"overdue": "逾期",
"complete": "完成"
},
"riskLevel": {
"low": "低風險",
"medium": "中風險",
"high": "高風險",
"critical": "危急"
},
"indicators": {
"title": "健康指標",

View File

@@ -2,6 +2,21 @@
"title": "設定",
"projectSettings": "專案設定",
"backToTasks": "返回任務",
"mySettings": {
"title": "個人設定",
"profile": "個人資訊",
"email": "電子郵件",
"department": "部門",
"role": "角色",
"workloadSettings": "工作負載設定",
"capacityDescription": "設定您每週可用的工作時數,用於計算工作負載百分比。",
"weeklyCapacity": "每週容量",
"hoursPerWeek": "小時/週",
"capacityHelp": "建議值40 小時標準工時。最大值168 小時(一週總時數)。",
"capacitySaved": "容量設定已儲存",
"capacityError": "儲存容量設定失敗",
"capacityInvalid": "請輸入有效的時數0-168"
},
"tabs": {
"general": "一般",
"members": "成員",
@@ -37,16 +52,58 @@
"add": "新增欄位",
"edit": "編輯欄位",
"delete": "刪除欄位",
"create": "建立欄位",
"fieldName": "欄位名稱",
"fieldNamePlaceholder": "例如:故事點數、衝刺編號",
"fieldType": "欄位類型",
"required": "必填",
"requiredField": "必填欄位",
"requiredHelp": "建立或更新任務時必須填寫必填欄位。",
"cannotChangeType": "無法變更",
"description": "自訂欄位允許您為任務新增額外資料。每個專案最多可建立 20 個欄位。",
"loading": "載入自訂欄位中...",
"loadError": "載入自訂欄位失敗",
"retry": "重試",
"empty": "尚未定義任何自訂欄位。",
"emptyHint": "點擊「新增欄位」建立您的第一個自訂欄位。",
"deleteConfirmTitle": "刪除自訂欄位?",
"deleteConfirmMessage": "這將永久刪除此欄位及所有任務中儲存的值。此操作無法復原。",
"deleting": "刪除中...",
"deleted": "自訂欄位已刪除",
"deleteError": "刪除欄位失敗",
"saving": "儲存中...",
"saveChanges": "儲存變更",
"saveError": "儲存欄位失敗",
"options": "選項",
"optionPlaceholder": "選項 {{index}}",
"addOption": "新增選項",
"optionRequired": "下拉欄位至少需要一個選項",
"formula": "公式運算式",
"formulaPlaceholder": "例如:{time_spent} / {original_estimate} * 100",
"formulaRequired": "公式運算式為必填",
"formulaHelp": {
"intro": "使用大括號來參照其他欄位:",
"customField": "參照自訂數字欄位",
"estimate": "任務預估時間",
"timeSpent": "已記錄時間",
"operators": "支援的運算子:+, -, *, /"
},
"types": {
"text": "文字",
"textDesc": "單行文字輸入",
"number": "數字",
"numberDesc": "數值",
"date": "日期",
"select": "下拉選單",
"multiSelect": "多選",
"checkbox": "核取方塊"
"dateDesc": "日期選擇器",
"dropdown": "下拉選單",
"dropdownDesc": "從預設選項中選擇",
"person": "人員",
"personDesc": "使用者指派",
"formula": "公式",
"formulaDesc": "從其他欄位計算"
},
"validation": {
"nameRequired": "欄位名稱為必填"
}
},
"notifications": {

View File

@@ -30,13 +30,43 @@
"overloaded": "超載",
"underutilized": "低使用率"
},
"table": {
"member": "團隊成員",
"department": "部門",
"allocated": "已分配",
"capacity": "容量",
"load": "負載",
"status": "狀態"
},
"status": {
"balanced": "平衡",
"normal": "正常",
"warning": "警告",
"overloaded": "超載",
"unavailable": "無法使用",
"underutilized": "低使用率"
},
"empty": {
"title": "沒有工作負載資料",
"description": "目前沒有足夠的資料來顯示工作負載"
"description": "目前沒有足夠的資料來顯示工作負載",
"noTasks": "本週沒有任何人被指派任務",
"hint": "當團隊成員被指派任務且截止日期在本週時,他們將會顯示在這裡。"
},
"options": {
"showAllUsers": "顯示所有用戶",
"showAllUsersHint": "(包括沒有任務的用戶)"
},
"calculation": {
"title": "工作負載計算方式",
"formula": "工作負載 = 本週任務預估工時 ÷ 個人週容量 × 100%",
"requirements": "任務需符合以下條件才會計入:",
"req1": "任務已指派給該成員",
"req2": "任務截止日期在選擇的週內",
"req3": "任務設有預估工時original_estimate",
"req4": "任務尚未完成",
"thresholds": "負載等級閾值:",
"normal": "正常:< 80%",
"warning": "警告80% - 99%",
"overloaded": "超載:≥ 100%"
}
}

View File

@@ -7,6 +7,7 @@ import Spaces from './pages/Spaces'
import Projects from './pages/Projects'
import Tasks from './pages/Tasks'
import ProjectSettings from './pages/ProjectSettings'
import MySettings from './pages/MySettings'
import AuditPage from './pages/AuditPage'
import WorkloadPage from './pages/WorkloadPage'
import ProjectHealthPage from './pages/ProjectHealthPage'
@@ -111,6 +112,16 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/my-settings"
element={
<ProtectedRoute>
<Layout>
<MySettings />
</Layout>
</ProtectedRoute>
}
/>
</Routes>
)
}

View File

@@ -23,6 +23,7 @@ interface Task {
start_date: string | null
time_estimate: number | null
subtask_count: number
parent_task_id: string | null
}
interface TaskStatus {
@@ -249,7 +250,11 @@ export function CalendarView({
}
// Optimistic update - event is already moved in the calendar
const newDueDate = newDate.toISOString().split('T')[0]
// Format date in local timezone (not UTC)
const year = newDate.getFullYear()
const month = String(newDate.getMonth() + 1).padStart(2, '0')
const day = String(newDate.getDate()).padStart(2, '0')
const newDueDate = `${year}-${month}-${day}`
try {
await api.patch(`/tasks/${task.id}`, {

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import {
customFieldsApi,
CustomField,
@@ -14,21 +15,22 @@ interface CustomFieldEditorProps {
onSave: () => void
}
const FIELD_TYPES: { value: FieldType; label: string; description: string }[] = [
{ value: 'text', label: 'Text', description: 'Single line text input' },
{ value: 'number', label: 'Number', description: 'Numeric value' },
{ value: 'dropdown', label: 'Dropdown', description: 'Select from predefined options' },
{ value: 'date', label: 'Date', description: 'Date picker' },
{ value: 'person', label: 'Person', description: 'User assignment' },
{ value: 'formula', label: 'Formula', description: 'Calculated from other fields' },
]
export function CustomFieldEditor({
projectId,
field,
onClose,
onSave,
}: CustomFieldEditorProps) {
const { t } = useTranslation('settings')
const FIELD_TYPES: { value: FieldType; label: string; description: string }[] = [
{ value: 'text', label: t('customFields.types.text'), description: t('customFields.types.textDesc') },
{ value: 'number', label: t('customFields.types.number'), description: t('customFields.types.numberDesc') },
{ value: 'dropdown', label: t('customFields.types.dropdown'), description: t('customFields.types.dropdownDesc') },
{ value: 'date', label: t('customFields.types.date'), description: t('customFields.types.dateDesc') },
{ value: 'person', label: t('customFields.types.person'), description: t('customFields.types.personDesc') },
{ value: 'formula', label: t('customFields.types.formula'), description: t('customFields.types.formulaDesc') },
]
const isEditing = field !== null
const [name, setName] = useState(field?.name || '')
@@ -98,20 +100,20 @@ export function CustomFieldEditor({
const validateForm = (): boolean => {
if (!name.trim()) {
setError('Field name is required')
setError(t('customFields.validation.nameRequired'))
return false
}
if (fieldType === 'dropdown') {
const validOptions = options.filter((opt) => opt.trim())
if (validOptions.length === 0) {
setError('At least one option is required for dropdown fields')
setError(t('customFields.optionRequired'))
return false
}
}
if (fieldType === 'formula' && !formula.trim()) {
setError('Formula expression is required')
setError(t('customFields.formulaRequired'))
return false
}
@@ -164,7 +166,7 @@ export function CustomFieldEditor({
} catch (err: unknown) {
const errorMessage =
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
'Failed to save field'
t('customFields.saveError')
setError(errorMessage)
} finally {
setSaving(false)
@@ -184,9 +186,9 @@ export function CustomFieldEditor({
<div style={styles.modal}>
<div style={styles.header}>
<h2 id="custom-field-editor-title" style={styles.title}>
{isEditing ? 'Edit Custom Field' : 'Create Custom Field'}
{isEditing ? t('customFields.edit') : t('customFields.create')}
</h2>
<button onClick={onClose} style={styles.closeButton} aria-label="Close">
<button onClick={onClose} style={styles.closeButton} aria-label={t('common:buttons.close')}>
X
</button>
</div>
@@ -196,12 +198,12 @@ export function CustomFieldEditor({
{/* Field Name */}
<div style={styles.formGroup}>
<label style={styles.label}>Field Name *</label>
<label style={styles.label}>{t('customFields.fieldName')} *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Story Points, Sprint Number"
placeholder={t('customFields.fieldNamePlaceholder')}
style={styles.input}
maxLength={100}
/>
@@ -210,7 +212,7 @@ export function CustomFieldEditor({
{/* Field Type - only show for create mode */}
{!isEditing && (
<div style={styles.formGroup}>
<label style={styles.label}>Field Type *</label>
<label style={styles.label}>{t('customFields.fieldType')} *</label>
<div style={styles.typeGrid}>
{FIELD_TYPES.map((type) => (
<div
@@ -232,10 +234,10 @@ export function CustomFieldEditor({
{/* Show current type info for edit mode */}
{isEditing && (
<div style={styles.formGroup}>
<label style={styles.label}>Field Type</label>
<label style={styles.label}>{t('customFields.fieldType')}</label>
<div style={styles.typeDisplay}>
{FIELD_TYPES.find((t) => t.value === fieldType)?.label}
<span style={styles.typeNote}>(cannot be changed)</span>
{FIELD_TYPES.find((ft) => ft.value === fieldType)?.label}
<span style={styles.typeNote}>({t('customFields.cannotChangeType')})</span>
</div>
</div>
)}
@@ -243,7 +245,7 @@ export function CustomFieldEditor({
{/* Dropdown Options */}
{fieldType === 'dropdown' && (
<div style={styles.formGroup}>
<label style={styles.label}>Options *</label>
<label style={styles.label}>{t('customFields.options')} *</label>
<div style={styles.optionsList}>
{options.map((option, index) => (
<div key={index} style={styles.optionRow}>
@@ -251,14 +253,14 @@ export function CustomFieldEditor({
type="text"
value={option}
onChange={(e) => handleOptionChange(index, e.target.value)}
placeholder={`Option ${index + 1}`}
placeholder={t('customFields.optionPlaceholder', { index: index + 1 })}
style={styles.optionInput}
/>
{options.length > 1 && (
<button
onClick={() => handleRemoveOption(index)}
style={styles.removeOptionButton}
aria-label="Remove option"
aria-label={t('common:buttons.remove')}
>
X
</button>
@@ -267,7 +269,7 @@ export function CustomFieldEditor({
))}
</div>
<button onClick={handleAddOption} style={styles.addOptionButton}>
+ Add Option
+ {t('customFields.addOption')}
</button>
</div>
)}
@@ -275,28 +277,28 @@ export function CustomFieldEditor({
{/* Formula Expression */}
{fieldType === 'formula' && (
<div style={styles.formGroup}>
<label style={styles.label}>Formula Expression *</label>
<label style={styles.label}>{t('customFields.formula')} *</label>
<input
type="text"
value={formula}
onChange={(e) => setFormula(e.target.value)}
placeholder="e.g., {time_spent} / {original_estimate} * 100"
placeholder={t('customFields.formulaPlaceholder')}
style={styles.input}
/>
<div style={styles.formulaHelp}>
<p>Use curly braces to reference other fields:</p>
<p>{t('customFields.formulaHelp.intro')}</p>
<ul>
<li>
<code>{'{field_name}'}</code> - Reference a custom number field
<code>{'{field_name}'}</code> - {t('customFields.formulaHelp.customField')}
</li>
<li>
<code>{'{original_estimate}'}</code> - Task time estimate
<code>{'{original_estimate}'}</code> - {t('customFields.formulaHelp.estimate')}
</li>
<li>
<code>{'{time_spent}'}</code> - Logged time
<code>{'{time_spent}'}</code> - {t('customFields.formulaHelp.timeSpent')}
</li>
</ul>
<p>Supported operators: +, -, *, /</p>
<p>{t('customFields.formulaHelp.operators')}</p>
</div>
</div>
)}
@@ -310,24 +312,24 @@ export function CustomFieldEditor({
onChange={(e) => setIsRequired(e.target.checked)}
style={styles.checkbox}
/>
Required field
{t('customFields.requiredField')}
</label>
<div style={styles.checkboxHelp}>
Tasks cannot be created or updated without filling in required fields.
{t('customFields.requiredHelp')}
</div>
</div>
</div>
<div style={styles.footer}>
<button onClick={onClose} style={styles.cancelButton} disabled={saving}>
Cancel
{t('common:buttons.cancel')}
</button>
<button
onClick={handleSave}
style={styles.saveButton}
disabled={saving || !name.trim()}
>
{saving ? 'Saving...' : isEditing ? 'Save Changes' : 'Create Field'}
{saving ? t('customFields.saving') : isEditing ? t('customFields.saveChanges') : t('customFields.create')}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { customFieldsApi, CustomField, FieldType } from '../services/customFields'
import { CustomFieldEditor } from './CustomFieldEditor'
import { useToast } from '../contexts/ToastContext'
@@ -7,16 +8,8 @@ interface CustomFieldListProps {
projectId: string
}
const FIELD_TYPE_LABELS: Record<FieldType, string> = {
text: 'Text',
number: 'Number',
dropdown: 'Dropdown',
date: 'Date',
person: 'Person',
formula: 'Formula',
}
export function CustomFieldList({ projectId }: CustomFieldListProps) {
const { t } = useTranslation('settings')
const { showToast } = useToast()
const [fields, setFields] = useState<CustomField[]>([])
const [loading, setLoading] = useState(true)
@@ -26,6 +19,15 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
const fieldTypeLabels: Record<FieldType, string> = {
text: t('customFields.types.text'),
number: t('customFields.types.number'),
dropdown: t('customFields.types.dropdown'),
date: t('customFields.types.date'),
person: t('customFields.types.person'),
formula: t('customFields.types.formula'),
}
useEffect(() => {
loadFields()
}, [projectId])
@@ -38,7 +40,7 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
setFields(response.fields)
} catch (err) {
console.error('Failed to load custom fields:', err)
setError('Failed to load custom fields')
setError(t('customFields.loadError'))
} finally {
setLoading(false)
}
@@ -81,13 +83,13 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
await customFieldsApi.deleteCustomField(deleteConfirm)
setDeleteConfirm(null)
loadFields()
showToast('Custom field deleted successfully', 'success')
showToast(t('customFields.deleted'), 'success')
} catch (err: unknown) {
const errorMessage =
err instanceof Error
? err.message
: (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
'Failed to delete field'
t('customFields.deleteError')
showToast(errorMessage, 'error')
} finally {
setDeleting(false)
@@ -95,7 +97,7 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
}
if (loading) {
return <div style={styles.loading}>Loading custom fields...</div>
return <div style={styles.loading}>{t('customFields.loading')}</div>
}
if (error) {
@@ -103,7 +105,7 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
<div style={styles.error}>
<p>{error}</p>
<button onClick={loadFields} style={styles.retryButton}>
Retry
{t('customFields.retry')}
</button>
</div>
)
@@ -112,23 +114,18 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
return (
<div style={styles.container}>
<div style={styles.header}>
<h3 style={styles.title}>Custom Fields</h3>
<h3 style={styles.title}>{t('customFields.title')}</h3>
<button onClick={handleCreate} style={styles.addButton}>
+ Add Field
+ {t('customFields.add')}
</button>
</div>
<p style={styles.description}>
Custom fields allow you to add additional data to tasks. You can create up to 20
fields per project.
</p>
<p style={styles.description}>{t('customFields.description')}</p>
{fields.length === 0 ? (
<div style={styles.emptyState}>
<p>No custom fields defined yet.</p>
<p style={styles.emptyHint}>
Click "Add Field" to create your first custom field.
</p>
<p>{t('customFields.empty')}</p>
<p style={styles.emptyHint}>{t('customFields.emptyHint')}</p>
</div>
) : (
<div style={styles.fieldList}>
@@ -137,15 +134,15 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
<div style={styles.fieldInfo}>
<div style={styles.fieldName}>
{field.name}
{field.is_required && <span style={styles.requiredBadge}>Required</span>}
{field.is_required && (
<span style={styles.requiredBadge}>{t('customFields.required')}</span>
)}
</div>
<div style={styles.fieldMeta}>
<span style={styles.fieldType}>
{FIELD_TYPE_LABELS[field.field_type]}
</span>
<span style={styles.fieldType}>{fieldTypeLabels[field.field_type]}</span>
{field.field_type === 'dropdown' && field.options && (
<span style={styles.optionCount}>
{field.options.length} option{field.options.length !== 1 ? 's' : ''}
{field.options.length} {t('customFields.options').toLowerCase()}
</span>
)}
{field.field_type === 'formula' && field.formula && (
@@ -157,16 +154,16 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
<button
onClick={() => handleEdit(field)}
style={styles.editButton}
aria-label={`Edit ${field.name}`}
aria-label={`${t('customFields.edit')} ${field.name}`}
>
Edit
{t('common:buttons.edit')}
</button>
<button
onClick={() => handleDeleteClick(field.id)}
style={styles.deleteButton}
aria-label={`Delete ${field.name}`}
aria-label={`${t('customFields.delete')} ${field.name}`}
>
Delete
{t('common:buttons.delete')}
</button>
</div>
</div>
@@ -188,25 +185,22 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
{deleteConfirm && (
<div style={styles.modalOverlay}>
<div style={styles.confirmModal}>
<h3 style={styles.confirmTitle}>Delete Custom Field?</h3>
<p style={styles.confirmMessage}>
This will permanently delete this field and all stored values for all tasks.
This action cannot be undone.
</p>
<h3 style={styles.confirmTitle}>{t('customFields.deleteConfirmTitle')}</h3>
<p style={styles.confirmMessage}>{t('customFields.deleteConfirmMessage')}</p>
<div style={styles.confirmActions}>
<button
onClick={handleDeleteCancel}
style={styles.cancelButton}
disabled={deleting}
>
Cancel
{t('common:buttons.cancel')}
</button>
<button
onClick={handleDeleteConfirm}
style={styles.confirmDeleteButton}
disabled={deleting}
>
{deleting ? 'Deleting...' : 'Delete'}
{deleting ? t('customFields.deleting') : t('common:buttons.delete')}
</button>
</div>
</div>
@@ -277,7 +271,7 @@ const styles: Record<string, React.CSSProperties> = {
},
emptyHint: {
fontSize: '13px',
color: '#767676', // WCAG AA compliant
color: '#767676',
marginTop: '8px',
},
fieldList: {

View File

@@ -18,6 +18,7 @@ interface Task {
start_date: string | null
time_estimate: number | null
subtask_count: number
parent_task_id: string | null
progress?: number
}
@@ -245,9 +246,15 @@ export function GanttChart({
setError(null)
setLoading(true)
// Format dates
const startDate = start.toISOString().split('T')[0]
const dueDate = end.toISOString().split('T')[0]
// Format dates in local timezone (not UTC)
const formatLocalDate = (d: Date) => {
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const startDate = formatLocalDate(start)
const dueDate = formatLocalDate(end)
try {
await api.patch(`/tasks/${taskId}`, {
@@ -725,7 +732,7 @@ const styles: Record<string, React.CSSProperties> = {
},
viewModeButtonActive: {
backgroundColor: '#0066cc',
borderColor: '#0066cc',
border: '1px solid #0066cc',
color: 'white',
},
loadingIndicator: {

View File

@@ -17,6 +17,7 @@ interface Task {
start_date: string | null
time_estimate: number | null
subtask_count: number
parent_task_id: string | null
custom_values?: CustomValueResponse[]
}

View File

@@ -52,7 +52,13 @@ export default function Layout({ children }: LayoutProps) {
<div style={styles.headerRight}>
<LanguageSwitcher />
<NotificationBell />
<span style={styles.userName}>{user?.name}</span>
<button
onClick={() => navigate('/my-settings')}
style={styles.userNameButton}
title={t('nav.settings')}
>
{user?.name}
</button>
{user?.is_system_admin && (
<span style={styles.badge}>Admin</span>
)}
@@ -114,9 +120,15 @@ const styles: { [key: string]: React.CSSProperties } = {
alignItems: 'center',
gap: '12px',
},
userName: {
color: '#666',
userNameButton: {
background: 'none',
border: 'none',
color: '#0066cc',
fontSize: '14px',
cursor: 'pointer',
padding: '4px 8px',
borderRadius: '4px',
textDecoration: 'underline',
},
badge: {
backgroundColor: '#0066cc',

View File

@@ -1,11 +1,162 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, CSSProperties } from 'react'
import { useTranslation } from 'react-i18next'
import { useNotifications } from '../contexts/NotificationContext'
import { SkeletonList } from './Skeleton'
const styles: Record<string, CSSProperties> = {
container: {
position: 'relative',
},
button: {
position: 'relative',
padding: '8px',
color: '#4b5563',
background: 'none',
border: 'none',
cursor: 'pointer',
},
buttonHover: {
color: '#111827',
},
icon: {
width: '24px',
height: '24px',
},
badge: {
position: 'absolute',
top: '-4px',
right: '-4px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2px 6px',
fontSize: '11px',
fontWeight: 'bold',
lineHeight: '1',
color: 'white',
backgroundColor: '#dc2626',
borderRadius: '9999px',
minWidth: '18px',
},
dropdown: {
position: 'absolute',
right: '0',
marginTop: '8px',
width: '320px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
border: '1px solid #e5e7eb',
zIndex: 50,
},
header: {
padding: '12px 16px',
borderBottom: '1px solid #e5e7eb',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
headerTitle: {
fontWeight: 600,
margin: 0,
fontSize: '14px',
},
markAllButton: {
fontSize: '13px',
color: '#2563eb',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
},
notificationList: {
maxHeight: '384px',
overflowY: 'auto',
},
loadingContainer: {
padding: '8px',
},
emptyState: {
padding: '16px',
textAlign: 'center',
color: '#6b7280',
fontSize: '14px',
},
notificationItem: {
padding: '12px 16px',
borderBottom: '1px solid #e5e7eb',
cursor: 'pointer',
transition: 'background-color 0.15s',
},
notificationItemUnread: {
backgroundColor: '#eff6ff',
},
notificationItemHover: {
backgroundColor: '#f9fafb',
},
notificationContent: {
display: 'flex',
gap: '12px',
},
notificationIcon: {
fontSize: '20px',
flexShrink: 0,
},
notificationBody: {
flex: 1,
minWidth: 0,
},
notificationTitle: {
fontWeight: 500,
fontSize: '14px',
margin: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
notificationMessage: {
color: '#4b5563',
fontSize: '13px',
margin: '4px 0 0 0',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
notificationTime: {
color: '#9ca3af',
fontSize: '12px',
margin: '4px 0 0 0',
},
unreadDot: {
width: '8px',
height: '8px',
backgroundColor: '#2563eb',
borderRadius: '50%',
flexShrink: 0,
marginTop: '8px',
},
footer: {
padding: '8px',
borderTop: '1px solid #e5e7eb',
textAlign: 'center',
},
refreshButton: {
fontSize: '13px',
color: '#2563eb',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px 8px',
},
}
export function NotificationBell() {
const { t } = useTranslation('common')
const { notifications, unreadCount, loading, fetchNotifications, markAsRead, markAllAsRead } =
useNotifications()
const [isOpen, setIsOpen] = useState(false)
const [hoveredId, setHoveredId] = useState<string | null>(null)
const [buttonHovered, setButtonHovered] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside
@@ -51,22 +202,27 @@ export function NotificationBell() {
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
if (diffMins < 1) return t('notifications.time.justNow')
if (diffMins < 60) return t('notifications.time.minutesAgo', { count: diffMins })
if (diffHours < 24) return t('notifications.time.hoursAgo', { count: diffHours })
if (diffDays < 7) return t('notifications.time.daysAgo', { count: diffDays })
return date.toLocaleDateString()
}
return (
<div className="relative" ref={dropdownRef}>
<div style={styles.container} ref={dropdownRef}>
<button
onClick={handleOpen}
className="relative p-2 text-gray-600 hover:text-gray-900 focus:outline-none"
aria-label="Notifications"
onMouseEnter={() => setButtonHovered(true)}
onMouseLeave={() => setButtonHovered(false)}
style={{
...styles.button,
...(buttonHovered ? styles.buttonHover : {}),
}}
aria-label={t('notifications.title')}
>
<svg
className="w-6 h-6"
style={styles.icon}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -79,61 +235,65 @@ export function NotificationBell() {
/>
</svg>
{unreadCount > 0 && (
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white transform translate-x-1/2 -translate-y-1/2 bg-red-600 rounded-full">
<span style={styles.badge}>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border z-50">
<div className="p-3 border-b flex justify-between items-center">
<h3 className="font-semibold">Notifications</h3>
<div style={styles.dropdown}>
<div style={styles.header}>
<h3 style={styles.headerTitle}>{t('notifications.title')}</h3>
{unreadCount > 0 && (
<button
onClick={() => markAllAsRead()}
className="text-sm text-blue-600 hover:underline"
style={styles.markAllButton}
>
Mark all read
{t('notifications.markAllRead')}
</button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
<div style={styles.notificationList}>
{loading ? (
<div className="p-2">
<div style={styles.loadingContainer}>
<SkeletonList count={3} showAvatar={false} />
</div>
) : notifications.length === 0 ? (
<div className="p-4 text-center text-gray-500">No notifications</div>
<div style={styles.emptyState}>{t('notifications.empty')}</div>
) : (
notifications.map(notification => (
<div
key={notification.id}
onClick={() => !notification.is_read && markAsRead(notification.id)}
className={`p-3 border-b cursor-pointer hover:bg-gray-50 ${
!notification.is_read ? 'bg-blue-50' : ''
}`}
onMouseEnter={() => setHoveredId(notification.id)}
onMouseLeave={() => setHoveredId(null)}
style={{
...styles.notificationItem,
...(!notification.is_read ? styles.notificationItemUnread : {}),
...(hoveredId === notification.id ? styles.notificationItemHover : {}),
}}
>
<div className="flex gap-3">
<span className="text-xl">
<div style={styles.notificationContent}>
<span style={styles.notificationIcon}>
{getNotificationIcon(notification.type)}
</span>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">
<div style={styles.notificationBody}>
<p style={styles.notificationTitle}>
{notification.title}
</p>
{notification.message && (
<p className="text-gray-600 text-sm truncate">
<p style={styles.notificationMessage}>
{notification.message}
</p>
)}
<p className="text-gray-400 text-xs mt-1">
<p style={styles.notificationTime}>
{formatTime(notification.created_at)}
</p>
</div>
{!notification.is_read && (
<span className="w-2 h-2 bg-blue-600 rounded-full flex-shrink-0 mt-2" />
<span style={styles.unreadDot} />
)}
</div>
</div>
@@ -142,12 +302,12 @@ export function NotificationBell() {
</div>
{notifications.length > 0 && (
<div className="p-2 border-t text-center">
<div style={styles.footer}>
<button
onClick={() => fetchNotifications()}
className="text-sm text-blue-600 hover:underline"
style={styles.refreshButton}
>
Refresh
{t('notifications.refresh')}
</button>
</div>
)}

View File

@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { ProjectHealthItem, RiskLevel, ScheduleStatus, ResourceStatus } from '../services/projectHealth'
interface ProjectHealthCardProps {
@@ -13,31 +14,32 @@ function getHealthScoreColor(score: number): string {
return '#f44336' // Red
}
// Risk level colors and labels
const riskLevelConfig: Record<RiskLevel, { color: string; bgColor: string; label: string }> = {
low: { color: '#2e7d32', bgColor: '#e8f5e9', label: 'Low Risk' },
medium: { color: '#f57c00', bgColor: '#fff3e0', label: 'Medium Risk' },
high: { color: '#d84315', bgColor: '#fbe9e7', label: 'High Risk' },
critical: { color: '#c62828', bgColor: '#ffebee', label: 'Critical' },
// Risk level colors
const riskLevelColors: Record<RiskLevel, { color: string; bgColor: string }> = {
low: { color: '#2e7d32', bgColor: '#e8f5e9' },
medium: { color: '#f57c00', bgColor: '#fff3e0' },
high: { color: '#d84315', bgColor: '#fbe9e7' },
critical: { color: '#c62828', bgColor: '#ffebee' },
}
// Schedule status labels
const scheduleStatusLabels: Record<ScheduleStatus, string> = {
on_track: 'On Track',
at_risk: 'At Risk',
delayed: 'Delayed',
// Schedule status translation keys
const scheduleStatusKeys: Record<ScheduleStatus, string> = {
on_track: 'status.onTrack',
at_risk: 'status.atRisk',
delayed: 'status.delayed',
}
// Resource status labels
const resourceStatusLabels: Record<ResourceStatus, string> = {
adequate: 'Adequate',
constrained: 'Constrained',
overloaded: 'Overloaded',
// Resource status translation keys
const resourceStatusKeys: Record<ResourceStatus, string> = {
adequate: 'resourceStatus.adequate',
constrained: 'resourceStatus.constrained',
overloaded: 'resourceStatus.overloaded',
}
export function ProjectHealthCard({ project, onClick }: ProjectHealthCardProps) {
const { t } = useTranslation('health')
const healthColor = getHealthScoreColor(project.health_score)
const riskConfig = riskLevelConfig[project.risk_level]
const riskColors = riskLevelColors[project.risk_level]
const progressPercent = project.task_count > 0
? Math.round((project.completed_task_count / project.task_count) * 100)
: 0
@@ -72,11 +74,11 @@ export function ProjectHealthCard({ project, onClick }: ProjectHealthCardProps)
<div
style={{
...styles.riskBadge,
color: riskConfig.color,
backgroundColor: riskConfig.bgColor,
color: riskColors.color,
backgroundColor: riskColors.bgColor,
}}
>
{riskConfig.label}
{t(`riskLevel.${project.risk_level}`)}
</div>
</div>
@@ -110,25 +112,25 @@ export function ProjectHealthCard({ project, onClick }: ProjectHealthCardProps)
<span style={{ ...styles.scoreValue, color: healthColor }}>
{project.health_score}
</span>
<span style={styles.scoreLabel}>Health</span>
<span style={styles.scoreLabel}>{t('card.health')}</span>
</div>
</div>
<div style={styles.statusSection}>
<div style={styles.statusItem}>
<span style={styles.statusLabel}>Schedule</span>
<span style={styles.statusLabel}>{t('card.schedule')}</span>
<span style={styles.statusValue}>
{scheduleStatusLabels[project.schedule_status]}
{t(scheduleStatusKeys[project.schedule_status])}
</span>
</div>
<div style={styles.statusItem}>
<span style={styles.statusLabel}>Resources</span>
<span style={styles.statusLabel}>{t('card.resources')}</span>
<span style={styles.statusValue}>
{resourceStatusLabels[project.resource_status]}
{t(resourceStatusKeys[project.resource_status])}
</span>
</div>
{project.owner_name && (
<div style={styles.statusItem}>
<span style={styles.statusLabel}>Owner</span>
<span style={styles.statusLabel}>{t('card.owner')}</span>
<span style={styles.statusValue}>{project.owner_name}</span>
</div>
)}
@@ -138,7 +140,7 @@ export function ProjectHealthCard({ project, onClick }: ProjectHealthCardProps)
{/* Task Progress */}
<div style={styles.progressSection}>
<div style={styles.progressHeader}>
<span style={styles.progressLabel}>Task Progress</span>
<span style={styles.progressLabel}>{t('card.taskProgress')}</span>
<span style={styles.progressValue}>
{project.completed_task_count} / {project.task_count}
</span>
@@ -158,17 +160,17 @@ export function ProjectHealthCard({ project, onClick }: ProjectHealthCardProps)
<div style={styles.metricsSection}>
<div style={styles.metricItem}>
<span style={styles.metricValue}>{project.blocker_count}</span>
<span style={styles.metricLabel}>Blockers</span>
<span style={styles.metricLabel}>{t('card.blockers')}</span>
</div>
<div style={styles.metricItem}>
<span style={{ ...styles.metricValue, color: project.overdue_task_count > 0 ? '#f44336' : 'inherit' }}>
{project.overdue_task_count}
</span>
<span style={styles.metricLabel}>Overdue</span>
<span style={styles.metricLabel}>{t('card.overdue')}</span>
</div>
<div style={styles.metricItem}>
<span style={styles.metricValue}>{progressPercent}%</span>
<span style={styles.metricLabel}>Complete</span>
<span style={styles.metricLabel}>{t('card.complete')}</span>
</div>
</div>
</div>

View File

@@ -18,6 +18,7 @@ interface SubtaskListProps {
projectId: string
onSubtaskClick?: (subtaskId: string) => void
onSubtaskCreated?: () => void
canAddSubtask?: boolean // If false, hide add subtask button (for depth limit)
}
export function SubtaskList({
@@ -25,6 +26,7 @@ export function SubtaskList({
projectId,
onSubtaskClick,
onSubtaskCreated,
canAddSubtask = true,
}: SubtaskListProps) {
const { t } = useTranslation('tasks')
const [subtasks, setSubtasks] = useState<Subtask[]>([])
@@ -170,47 +172,49 @@ export function SubtaskList({
<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}>
{t('subtasks.placeholder')}
</label>
<input
id="new-subtask-title"
type="text"
value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)}
placeholder={t('subtasks.placeholder')}
style={styles.input}
autoFocus
disabled={submitting}
/>
<div style={styles.formActions}>
<button
type="button"
onClick={handleCancelAdd}
style={styles.cancelButton}
{/* Add Subtask Form - only show if canAddSubtask is true */}
{canAddSubtask && (
showAddForm ? (
<form onSubmit={handleAddSubtask} style={styles.addForm}>
<label htmlFor="new-subtask-title" style={styles.visuallyHidden}>
{t('subtasks.placeholder')}
</label>
<input
id="new-subtask-title"
type="text"
value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)}
placeholder={t('subtasks.placeholder')}
style={styles.input}
autoFocus
disabled={submitting}
>
{t('common:buttons.cancel')}
</button>
<button
type="submit"
style={styles.submitButton}
disabled={!newSubtaskTitle.trim() || submitting}
>
{submitting ? t('subtasks.adding') : t('common:buttons.add')}
</button>
</div>
</form>
) : (
<button
onClick={() => setShowAddForm(true)}
style={styles.addButton}
>
+ {t('subtasks.add')}
</button>
/>
<div style={styles.formActions}>
<button
type="button"
onClick={handleCancelAdd}
style={styles.cancelButton}
disabled={submitting}
>
{t('common:buttons.cancel')}
</button>
<button
type="submit"
style={styles.submitButton}
disabled={!newSubtaskTitle.trim() || submitting}
>
{submitting ? t('subtasks.adding') : t('common:buttons.add')}
</button>
</div>
</form>
) : (
<button
onClick={() => setShowAddForm(true)}
style={styles.addButton}
>
+ {t('subtasks.add')}
</button>
)
)}
</>
)}

View File

@@ -24,6 +24,7 @@ interface Task {
due_date: string | null
time_estimate: number | null
subtask_count: number
parent_task_id: string | null
custom_values?: CustomValueResponse[]
}
@@ -159,16 +160,13 @@ export function TaskDetailModal({
priority: editForm.priority,
}
if (editForm.status_id) {
payload.status_id = editForm.status_id
}
if (editForm.assignee_id) {
payload.assignee_id = editForm.assignee_id
} else {
payload.assignee_id = null
}
// Always send status_id (null to clear, or the value)
payload.status_id = editForm.status_id || null
// Always send assignee_id (null to clear, or the value)
payload.assignee_id = editForm.assignee_id || null
if (editForm.due_date) {
payload.due_date = editForm.due_date
// Convert date string to datetime format for Pydantic 2
payload.due_date = `${editForm.due_date}T00:00:00`
} else {
payload.due_date = null
}
@@ -322,13 +320,14 @@ export function TaskDetailModal({
<TaskAttachments taskId={task.id} />
</div>
{/* Subtasks Section */}
{/* Subtasks Section - only allow adding subtasks if this is not already a subtask (depth limit = 2) */}
<div style={styles.section}>
<SubtaskList
taskId={task.id}
projectId={task.project_id}
onSubtaskClick={onSubtaskClick}
onSubtaskCreated={onUpdate}
canAddSubtask={!task.parent_task_id}
/>
</div>
</div>
@@ -398,7 +397,8 @@ export function TaskDetailModal({
<label style={styles.sidebarLabel}>{t('fields.assignee')}</label>
{isEditing ? (
<UserSelect
value={editForm.assignee_id}
value={editForm.assignee_id || null}
valueName={task.assignee_name}
onChange={handleAssigneeChange}
placeholder={t('common:labels.selectAssignee')}
/>

View File

@@ -3,6 +3,7 @@ import { usersApi, UserSearchResult } from '../services/collaboration'
interface UserSelectProps {
value: string | null
valueName?: string | null // Optional: display name for the current value
onChange: (userId: string | null, user: UserSearchResult | null) => void
placeholder?: string
disabled?: boolean
@@ -10,6 +11,7 @@ interface UserSelectProps {
export function UserSelect({
value,
valueName,
onChange,
placeholder = 'Select assignee...',
disabled = false,
@@ -18,10 +20,21 @@ export function UserSelect({
const [searchQuery, setSearchQuery] = useState('')
const [users, setUsers] = useState<UserSearchResult[]>([])
const [loading, setLoading] = useState(false)
const [selectedUser, setSelectedUser] = useState<UserSearchResult | null>(null)
const [selectedUser, setSelectedUser] = useState<UserSearchResult | null>(
value && valueName ? { id: value, name: valueName, email: '' } : null
)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Sync selectedUser when value/valueName props change
useEffect(() => {
if (value && valueName) {
setSelectedUser({ id: value, name: valueName, email: '' })
} else if (!value) {
setSelectedUser(null)
}
}, [value, valueName])
// Fetch users based on search query
const searchUsers = useCallback(async (query: string) => {
if (query.length < 1) {

View File

@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { WorkloadUser, LoadLevel } from '../services/workload'
interface WorkloadHeatmapProps {
@@ -15,14 +16,15 @@ const loadLevelColors: Record<LoadLevel, string> = {
unavailable: '#9e9e9e',
}
const loadLevelLabels: Record<LoadLevel, string> = {
normal: 'Normal',
warning: 'Warning',
overloaded: 'Overloaded',
unavailable: 'Unavailable',
}
export function WorkloadHeatmap({ users, weekStart, weekEnd, onUserClick }: WorkloadHeatmapProps) {
const { t } = useTranslation('workload')
const loadLevelLabels: Record<LoadLevel, string> = {
normal: t('status.normal'),
warning: t('status.warning'),
overloaded: t('status.overloaded'),
unavailable: t('status.unavailable'),
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-TW', { month: 'short', day: 'numeric' })
@@ -43,7 +45,8 @@ export function WorkloadHeatmap({ users, weekStart, weekEnd, onUserClick }: Work
if (users.length === 0) {
return (
<div style={styles.emptyState}>
<p>No workload data available for this week.</p>
<p>{t('empty.noTasks')}</p>
<p style={{ fontSize: '13px', color: '#888', marginTop: '8px' }}>{t('empty.hint')}</p>
</div>
)
}
@@ -73,12 +76,12 @@ export function WorkloadHeatmap({ users, weekStart, weekEnd, onUserClick }: Work
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Team Member</th>
<th style={styles.th}>Department</th>
<th style={styles.th}>Allocated</th>
<th style={styles.th}>Capacity</th>
<th style={styles.th}>Load</th>
<th style={styles.th}>Status</th>
<th style={styles.th}>{t('table.member')}</th>
<th style={styles.th}>{t('table.department')}</th>
<th style={styles.th}>{t('table.allocated')}</th>
<th style={styles.th}>{t('table.capacity')}</th>
<th style={styles.th}>{t('table.load')}</th>
<th style={styles.th}>{t('table.status')}</th>
</tr>
</thead>
<tbody>

View File

@@ -116,21 +116,21 @@ export function WorkloadUserDetail({
<div style={styles.summarySection}>
<div style={styles.summaryCard}>
<span style={styles.summaryLabel}>Allocated Hours</span>
<span style={styles.summaryValue}>{detail.summary.allocated_hours}h</span>
<span style={styles.summaryValue}>{detail.allocated_hours}h</span>
</div>
<div style={styles.summaryCard}>
<span style={styles.summaryLabel}>Capacity</span>
<span style={styles.summaryValue}>{detail.summary.capacity_hours}h</span>
<span style={styles.summaryValue}>{detail.capacity_hours}h</span>
</div>
<div style={styles.summaryCard}>
<span style={styles.summaryLabel}>Load</span>
<span
style={{
...styles.summaryValue,
color: loadLevelColors[detail.summary.load_level],
color: loadLevelColors[detail.load_level],
}}
>
{detail.summary.load_percentage}%
{detail.load_percentage}%
</span>
</div>
<div style={styles.summaryCard}>
@@ -138,10 +138,10 @@ export function WorkloadUserDetail({
<span
style={{
...styles.statusBadge,
backgroundColor: loadLevelColors[detail.summary.load_level],
backgroundColor: loadLevelColors[detail.load_level],
}}
>
{loadLevelLabels[detail.summary.load_level]}
{loadLevelLabels[detail.load_level]}
</span>
</div>
</div>
@@ -156,16 +156,16 @@ export function WorkloadUserDetail({
{detail.tasks.map((task) => (
<div key={task.task_id} style={styles.taskItem}>
<div style={styles.taskMain}>
<span style={styles.taskTitle}>{task.task_title}</span>
<span style={styles.taskTitle}>{task.title}</span>
<span style={styles.projectName}>{task.project_name}</span>
</div>
<div style={styles.taskMeta}>
<span style={styles.timeEstimate}>{task.time_estimate}h</span>
<span style={styles.timeEstimate}>{task.original_estimate ?? 0}h</span>
{task.due_date && (
<span style={styles.dueDate}>Due: {formatDate(task.due_date)}</span>
)}
{task.status_name && (
<span style={styles.status}>{task.status_name}</span>
{task.status && (
<span style={styles.status}>{task.status}</span>
)}
</div>
</div>
@@ -178,7 +178,7 @@ export function WorkloadUserDetail({
<div style={styles.totalSection}>
<span style={styles.totalLabel}>Total Estimated Hours:</span>
<span style={styles.totalValue}>
{detail.tasks.reduce((sum, task) => sum + task.time_estimate, 0)}h
{detail.tasks.reduce((sum, task) => sum + (task.original_estimate ?? 0), 0)}h
</span>
</div>
</>

View File

@@ -192,7 +192,9 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
isMountedRef.current = true
const token = localStorage.getItem('token')
if (token) {
// Fetch both unread count and initial notifications
refreshUnreadCount()
fetchNotifications()
// Delay WebSocket connection to avoid StrictMode race condition
const connectTimeout = setTimeout(() => {
if (isMountedRef.current) {
@@ -219,7 +221,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
return () => {
isMountedRef.current = false
}
}, [refreshUnreadCount, connectWebSocket])
}, [refreshUnreadCount, fetchNotifications, connectWebSocket])
return (
<NotificationContext.Provider

View File

@@ -0,0 +1,275 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext'
import { useToast } from '../contexts/ToastContext'
import api from '../services/api'
import { Skeleton } from '../components/Skeleton'
interface UserProfile {
id: string
name: string
email: string
capacity: number
department_name: string | null
role_name: string | null
}
export default function MySettings() {
const { t } = useTranslation(['settings', 'common'])
const { user } = useAuth()
const { showToast } = useToast()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [profile, setProfile] = useState<UserProfile | null>(null)
const [capacity, setCapacity] = useState<string>('')
const [error, setError] = useState<string | null>(null)
useEffect(() => {
loadProfile()
}, [])
const loadProfile = async () => {
setLoading(true)
try {
const response = await api.get(`/users/${user?.id}`)
const userData = response.data
setProfile({
id: userData.id,
name: userData.name,
email: userData.email,
capacity: userData.capacity || 40,
department_name: userData.department?.name || null,
role_name: userData.role?.name || null,
})
setCapacity(String(userData.capacity || 40))
} catch (err) {
console.error('Failed to load profile:', err)
setError(t('common:messages.error'))
} finally {
setLoading(false)
}
}
const handleSaveCapacity = async () => {
if (!profile) return
const capacityValue = parseFloat(capacity)
if (isNaN(capacityValue) || capacityValue < 0 || capacityValue > 168) {
setError(t('mySettings.capacityInvalid'))
return
}
setSaving(true)
setError(null)
try {
await api.put(`/users/${profile.id}/capacity`, {
capacity_hours: capacityValue,
})
setProfile({ ...profile, capacity: capacityValue })
showToast(t('mySettings.capacitySaved'), 'success')
} catch (err) {
console.error('Failed to save capacity:', err)
showToast(t('mySettings.capacityError'), 'error')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div style={styles.container}>
<Skeleton variant="text" width={200} height={32} style={{ marginBottom: '24px' }} />
<div style={styles.card}>
<Skeleton variant="rect" width="100%" height={200} />
</div>
</div>
)
}
if (error && !profile) {
return (
<div style={styles.container}>
<div style={styles.error}>{error}</div>
</div>
)
}
return (
<div style={styles.container}>
<h1 style={styles.title}>{t('mySettings.title')}</h1>
{/* Profile Info */}
<div style={styles.card}>
<h2 style={styles.cardTitle}>{t('mySettings.profile')}</h2>
<div style={styles.infoGrid}>
<div style={styles.infoRow}>
<span style={styles.infoLabel}>{t('common:labels.name')}</span>
<span style={styles.infoValue}>{profile?.name}</span>
</div>
<div style={styles.infoRow}>
<span style={styles.infoLabel}>{t('mySettings.email')}</span>
<span style={styles.infoValue}>{profile?.email}</span>
</div>
{profile?.department_name && (
<div style={styles.infoRow}>
<span style={styles.infoLabel}>{t('mySettings.department')}</span>
<span style={styles.infoValue}>{profile.department_name}</span>
</div>
)}
{profile?.role_name && (
<div style={styles.infoRow}>
<span style={styles.infoLabel}>{t('mySettings.role')}</span>
<span style={styles.infoValue}>{profile.role_name}</span>
</div>
)}
</div>
</div>
{/* Capacity Settings */}
<div style={styles.card}>
<h2 style={styles.cardTitle}>{t('mySettings.workloadSettings')}</h2>
<p style={styles.cardDescription}>{t('mySettings.capacityDescription')}</p>
<div style={styles.formGroup}>
<label style={styles.label}>{t('mySettings.weeklyCapacity')}</label>
<div style={styles.inputGroup}>
<input
type="number"
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
min="0"
max="168"
step="0.5"
style={styles.input}
/>
<span style={styles.inputSuffix}>{t('mySettings.hoursPerWeek')}</span>
</div>
<p style={styles.helpText}>{t('mySettings.capacityHelp')}</p>
</div>
{error && <div style={styles.errorMessage}>{error}</div>}
<button
onClick={handleSaveCapacity}
disabled={saving}
style={{
...styles.saveButton,
...(saving ? styles.saveButtonDisabled : {}),
}}
>
{saving ? t('common:labels.loading') : t('common:buttons.save')}
</button>
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
container: {
padding: '24px',
maxWidth: '800px',
margin: '0 auto',
},
title: {
fontSize: '24px',
fontWeight: 600,
margin: '0 0 24px 0',
},
card: {
backgroundColor: 'white',
borderRadius: '8px',
padding: '24px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
marginBottom: '24px',
},
cardTitle: {
fontSize: '18px',
fontWeight: 600,
margin: '0 0 16px 0',
},
cardDescription: {
fontSize: '14px',
color: '#666',
marginBottom: '20px',
},
infoGrid: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
},
infoRow: {
display: 'flex',
alignItems: 'center',
padding: '12px 0',
borderBottom: '1px solid #f0f0f0',
},
infoLabel: {
width: '120px',
fontSize: '14px',
fontWeight: 500,
color: '#666',
},
infoValue: {
flex: 1,
fontSize: '14px',
color: '#333',
},
formGroup: {
marginBottom: '20px',
},
label: {
display: 'block',
fontSize: '14px',
fontWeight: 500,
color: '#333',
marginBottom: '8px',
},
inputGroup: {
display: 'flex',
alignItems: 'center',
gap: '8px',
},
input: {
width: '120px',
padding: '10px 12px',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '14px',
},
inputSuffix: {
fontSize: '14px',
color: '#666',
},
helpText: {
fontSize: '12px',
color: '#888',
marginTop: '8px',
},
errorMessage: {
padding: '12px',
backgroundColor: '#ffebee',
color: '#f44336',
borderRadius: '4px',
marginBottom: '16px',
fontSize: '14px',
},
saveButton: {
padding: '10px 24px',
backgroundColor: '#0066cc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
},
saveButtonDisabled: {
backgroundColor: '#ccc',
cursor: 'not-allowed',
},
error: {
padding: '48px',
textAlign: 'center',
color: '#f44336',
},
}

View File

@@ -119,6 +119,12 @@ export default function ProjectHealthPage() {
</span>
<span style={styles.summaryLabel}>{t('summary.atRisk')}</span>
</div>
<div style={styles.summaryCard}>
<span style={{ ...styles.summaryValue, color: '#ff5722' }}>
{dashboardData.summary.high_risk_count}
</span>
<span style={styles.summaryLabel}>{t('summary.highRisk')}</span>
</div>
<div style={styles.summaryCard}>
<span style={{ ...styles.summaryValue, color: '#f44336' }}>
{dashboardData.summary.critical_count}
@@ -151,6 +157,28 @@ export default function ProjectHealthPage() {
</div>
)}
{/* Calculation Explanation */}
<div style={styles.calculationSection}>
<details style={styles.calculationDetails}>
<summary style={styles.calculationSummary}>{t('calculation.title')}</summary>
<div style={styles.calculationContent}>
<p style={styles.calculationText}>{t('calculation.formula')}</p>
<ul style={styles.calculationList}>
<li>{t('calculation.blockers')}</li>
<li>{t('calculation.overdue')}</li>
<li>{t('calculation.completion')}</li>
</ul>
<p style={styles.calculationText}>{t('calculation.thresholds')}</p>
<ul style={styles.calculationList}>
<li style={{ color: '#4caf50' }}>{t('calculation.lowRisk')}</li>
<li style={{ color: '#ff9800' }}>{t('calculation.mediumRisk')}</li>
<li style={{ color: '#ff5722' }}>{t('calculation.highRiskLevel')}</li>
<li style={{ color: '#f44336' }}>{t('calculation.criticalRisk')}</li>
</ul>
</div>
</details>
</div>
{/* Sort Controls */}
{dashboardData && dashboardData.projects.length > 0 && (
<div style={styles.controlsContainer}>
@@ -340,4 +368,37 @@ const styles: { [key: string]: React.CSSProperties } = {
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: '20px',
},
calculationSection: {
marginBottom: '20px',
},
calculationDetails: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
overflow: 'hidden',
},
calculationSummary: {
padding: '12px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
color: '#333',
backgroundColor: '#f8f9fa',
borderBottom: '1px solid #eee',
},
calculationContent: {
padding: '16px',
},
calculationText: {
fontSize: '13px',
color: '#666',
margin: '0 0 8px 0',
},
calculationList: {
margin: '0 0 12px 0',
paddingLeft: '20px',
fontSize: '13px',
color: '#333',
lineHeight: 1.8,
},
}

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import api from '../services/api'
import { SkeletonGrid } from '../components/Skeleton'
import { useToast } from '../contexts/ToastContext'
import { useAuth } from '../contexts/AuthContext'
interface Project {
id: string
@@ -28,6 +29,7 @@ export default function Projects() {
const { spaceId } = useParams()
const navigate = useNavigate()
const { showToast } = useToast()
const { user } = useAuth()
const [space, setSpace] = useState<Space | null>(null)
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
@@ -38,30 +40,43 @@ export default function Projects() {
security_level: 'department',
})
const [creating, setCreating] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null)
const [deleting, setDeleting] = useState(false)
const modalOverlayRef = useRef<HTMLDivElement>(null)
const deleteModalRef = useRef<HTMLDivElement>(null)
useEffect(() => {
loadData()
}, [spaceId])
// Handle Escape key to close modal - document-level listener
// Handle Escape key to close modals - document-level listener
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && showCreateModal) {
setShowCreateModal(false)
if (e.key === 'Escape') {
if (showDeleteModal) {
setShowDeleteModal(false)
setProjectToDelete(null)
} else if (showCreateModal) {
setShowCreateModal(false)
}
}
}
if (showCreateModal) {
if (showCreateModal || showDeleteModal) {
document.addEventListener('keydown', handleKeyDown)
// Focus the overlay for accessibility
modalOverlayRef.current?.focus()
if (showDeleteModal) {
deleteModalRef.current?.focus()
} else {
modalOverlayRef.current?.focus()
}
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [showCreateModal])
}, [showCreateModal, showDeleteModal])
const loadData = async () => {
try {
@@ -97,6 +112,34 @@ export default function Projects() {
}
}
const handleDeleteClick = (e: React.MouseEvent, project: Project) => {
e.stopPropagation()
setProjectToDelete(project)
setShowDeleteModal(true)
}
const handleDeleteProject = async () => {
if (!projectToDelete) return
setDeleting(true)
try {
await api.delete(`/projects/${projectToDelete.id}`)
setShowDeleteModal(false)
setProjectToDelete(null)
loadData()
showToast(t('messages.deleted'), 'success')
} catch (err) {
console.error('Failed to delete project:', err)
showToast(t('common:messages.error'), 'error')
} finally {
setDeleting(false)
}
}
const isOwner = (project: Project) => {
return user?.id === project.owner_id || user?.is_system_admin
}
const getSecurityBadgeStyle = (level: string): React.CSSProperties => {
const colors: { [key: string]: { bg: string; text: string } } = {
public: { bg: '#e8f5e9', text: '#2e7d32' },
@@ -158,9 +201,21 @@ export default function Projects() {
>
<div style={styles.cardHeader}>
<h3 style={styles.cardTitle}>{project.title}</h3>
<span style={getSecurityBadgeStyle(project.security_level)}>
{project.security_level}
</span>
<div style={styles.cardActions}>
<span style={getSecurityBadgeStyle(project.security_level)}>
{project.security_level}
</span>
{isOwner(project) && (
<button
onClick={(e) => handleDeleteClick(e, project)}
style={styles.deleteButton}
title={t('deleteProject')}
aria-label={t('deleteProject')}
>
🗑
</button>
)}
</div>
</div>
<p style={styles.cardDescription}>
{project.description || t('card.noDescription')}
@@ -237,6 +292,45 @@ export default function Projects() {
</div>
</div>
)}
{showDeleteModal && projectToDelete && (
<div
ref={deleteModalRef}
style={styles.modalOverlay}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="delete-project-title"
>
<div style={styles.modal}>
<h2 id="delete-project-title" style={styles.modalTitle}>{t('deleteProject')}</h2>
<p style={styles.deleteWarning}>
{t('messages.confirmDelete')}
</p>
<p style={styles.deleteProjectName}>
<strong>{projectToDelete.title}</strong>
</p>
<div style={styles.modalActions}>
<button
onClick={() => {
setShowDeleteModal(false)
setProjectToDelete(null)
}}
style={styles.cancelButton}
>
{t('common:buttons.cancel')}
</button>
<button
onClick={handleDeleteProject}
disabled={deleting}
style={styles.deleteConfirmButton}
>
{deleting ? t('common:labels.loading') : t('common:buttons.delete')}
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -420,4 +514,38 @@ const styles: { [key: string]: React.CSSProperties } = {
whiteSpace: 'nowrap',
border: 0,
},
cardActions: {
display: 'flex',
alignItems: 'center',
gap: '8px',
},
deleteButton: {
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px 8px',
fontSize: '16px',
opacity: 0.6,
transition: 'opacity 0.2s',
},
deleteWarning: {
color: '#666',
fontSize: '14px',
marginBottom: '8px',
},
deleteProjectName: {
backgroundColor: '#f5f5f5',
padding: '12px',
borderRadius: '4px',
marginBottom: '16px',
fontSize: '14px',
},
deleteConfirmButton: {
padding: '10px 20px',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
},
}

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import api from '../services/api'
import { useToast } from '../contexts/ToastContext'
import { useAuth } from '../contexts/AuthContext'
import { SkeletonGrid } from '../components/Skeleton'
interface Space {
@@ -19,35 +20,49 @@ export default function Spaces() {
const { t } = useTranslation('spaces')
const navigate = useNavigate()
const { showToast } = useToast()
const { user } = useAuth()
const [spaces, setSpaces] = useState<Space[]>([])
const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [newSpace, setNewSpace] = useState({ name: '', description: '' })
const [creating, setCreating] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [spaceToDelete, setSpaceToDelete] = useState<Space | null>(null)
const [deleting, setDeleting] = useState(false)
const modalOverlayRef = useRef<HTMLDivElement>(null)
const deleteModalRef = useRef<HTMLDivElement>(null)
useEffect(() => {
loadSpaces()
}, [])
// Handle Escape key to close modal - document-level listener
// Handle Escape key to close modals - document-level listener
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && showCreateModal) {
setShowCreateModal(false)
if (e.key === 'Escape') {
if (showDeleteModal) {
setShowDeleteModal(false)
setSpaceToDelete(null)
} else if (showCreateModal) {
setShowCreateModal(false)
}
}
}
if (showCreateModal) {
if (showCreateModal || showDeleteModal) {
document.addEventListener('keydown', handleKeyDown)
// Focus the overlay for accessibility
modalOverlayRef.current?.focus()
if (showDeleteModal) {
deleteModalRef.current?.focus()
} else {
modalOverlayRef.current?.focus()
}
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [showCreateModal])
}, [showCreateModal, showDeleteModal])
const loadSpaces = async () => {
try {
@@ -79,6 +94,34 @@ export default function Spaces() {
}
}
const handleDeleteClick = (e: React.MouseEvent, space: Space) => {
e.stopPropagation()
setSpaceToDelete(space)
setShowDeleteModal(true)
}
const handleDeleteSpace = async () => {
if (!spaceToDelete) return
setDeleting(true)
try {
await api.delete(`/spaces/${spaceToDelete.id}`)
setShowDeleteModal(false)
setSpaceToDelete(null)
loadSpaces()
showToast(t('messages.deleted'), 'success')
} catch (err) {
console.error('Failed to delete space:', err)
showToast(t('common:messages.error'), 'error')
} finally {
setDeleting(false)
}
}
const isOwner = (space: Space) => {
return user?.id === space.owner_id || user?.is_system_admin
}
if (loading) {
return (
<div style={styles.container}>
@@ -116,7 +159,19 @@ export default function Spaces() {
tabIndex={0}
aria-label={`${t('title')}: ${space.name}`}
>
<h3 style={styles.cardTitle}>{space.name}</h3>
<div style={styles.cardHeader}>
<h3 style={styles.cardTitle}>{space.name}</h3>
{isOwner(space) && (
<button
onClick={(e) => handleDeleteClick(e, space)}
style={styles.deleteButton}
title={t('deleteSpace')}
aria-label={t('deleteSpace')}
>
🗑
</button>
)}
</div>
<p style={styles.cardDescription}>
{space.description || t('common:labels.noData')}
</p>
@@ -184,6 +239,45 @@ export default function Spaces() {
</div>
</div>
)}
{showDeleteModal && spaceToDelete && (
<div
ref={deleteModalRef}
style={styles.modalOverlay}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="delete-space-title"
>
<div style={styles.modal}>
<h2 id="delete-space-title" style={styles.modalTitle}>{t('deleteSpace')}</h2>
<p style={styles.deleteWarning}>
{t('messages.confirmDelete')}
</p>
<p style={styles.deleteSpaceName}>
<strong>{spaceToDelete.name}</strong>
</p>
<div style={styles.modalActions}>
<button
onClick={() => {
setShowDeleteModal(false)
setSpaceToDelete(null)
}}
style={styles.cancelButton}
>
{t('common:buttons.cancel')}
</button>
<button
onClick={handleDeleteSpace}
disabled={deleting}
style={styles.deleteConfirmButton}
>
{deleting ? t('common:labels.loading') : t('common:buttons.delete')}
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -228,10 +322,16 @@ const styles: { [key: string]: React.CSSProperties } = {
cursor: 'pointer',
transition: 'box-shadow 0.2s',
},
cardHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '8px',
},
cardTitle: {
fontSize: '18px',
fontWeight: 600,
marginBottom: '8px',
margin: 0,
},
cardDescription: {
color: '#666',
@@ -326,4 +426,33 @@ const styles: { [key: string]: React.CSSProperties } = {
whiteSpace: 'nowrap',
border: 0,
},
deleteButton: {
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px 8px',
fontSize: '16px',
opacity: 0.6,
transition: 'opacity 0.2s',
},
deleteWarning: {
color: '#666',
fontSize: '14px',
marginBottom: '8px',
},
deleteSpaceName: {
backgroundColor: '#f5f5f5',
padding: '12px',
borderRadius: '4px',
marginBottom: '16px',
fontSize: '14px',
},
deleteConfirmButton: {
padding: '10px 20px',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
},
}

View File

@@ -28,6 +28,7 @@ interface Task {
start_date: string | null
time_estimate: number | null
subtask_count: number
parent_task_id: string | null
custom_values?: CustomValueResponse[]
}
@@ -83,6 +84,7 @@ export default function Tasks() {
description: '',
priority: 'medium',
assignee_id: '',
start_date: '',
due_date: '',
time_estimate: '',
})
@@ -172,6 +174,7 @@ export default function Tasks() {
start_date: (event.data.start_date as string) ?? null,
time_estimate: event.data.time_estimate ?? event.data.original_estimate ?? null,
subtask_count: event.data.subtask_count ?? 0,
parent_task_id: (event.data.parent_task_id as string) ?? null,
}
return [...prev, newTask]
})
@@ -318,8 +321,13 @@ export default function Tasks() {
if (newTask.assignee_id) {
payload.assignee_id = newTask.assignee_id
}
if (newTask.start_date) {
// Convert date string to datetime format for Pydantic
payload.start_date = `${newTask.start_date}T00:00:00`
}
if (newTask.due_date) {
payload.due_date = newTask.due_date
// Convert date string to datetime format for Pydantic
payload.due_date = `${newTask.due_date}T00:00:00`
}
if (newTask.time_estimate) {
payload.original_estimate = Number(newTask.time_estimate)
@@ -347,6 +355,7 @@ export default function Tasks() {
description: '',
priority: 'medium',
assignee_id: '',
start_date: '',
due_date: '',
time_estimate: '',
})
@@ -419,8 +428,22 @@ export default function Tasks() {
setSelectedTask(null)
}
const handleTaskUpdate = () => {
loadData()
const handleTaskUpdate = async () => {
await loadData()
// If a task is selected (modal is open), re-fetch its data to show updated values
if (selectedTask) {
try {
const response = await api.get(`/tasks/${selectedTask.id}`)
const updatedTask = response.data
setSelectedTask({
...updatedTask,
project_id: projectId!,
time_estimate: updatedTask.original_estimate,
})
} catch (err) {
console.error('Failed to refresh selected task:', err)
}
}
}
const handleSubtaskClick = async (subtaskId: string) => {
@@ -742,6 +765,14 @@ export default function Tasks() {
/>
<div style={styles.fieldSpacer} />
<label style={styles.label}>{t('fields.startDate')}</label>
<input
type="date"
value={newTask.start_date}
onChange={(e) => setNewTask({ ...newTask, start_date: e.target.value })}
style={styles.input}
/>
<label style={styles.label}>{t('fields.dueDate')}</label>
<input
type="date"

View File

@@ -15,9 +15,12 @@ function getMonday(date: Date): Date {
return d
}
// Format date as YYYY-MM-DD
// Format date as YYYY-MM-DD (local timezone, not UTC)
function formatDateParam(date: Date): string {
return date.toISOString().split('T')[0]
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// Format date for display
@@ -37,12 +40,13 @@ export default function WorkloadPage() {
const [selectedWeek, setSelectedWeek] = useState<Date>(() => getMonday(new Date()))
const [selectedUser, setSelectedUser] = useState<{ id: string; name: string } | null>(null)
const [showUserDetail, setShowUserDetail] = useState(false)
const [showAllUsers, setShowAllUsers] = useState(false)
const loadHeatmap = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await workloadApi.getHeatmap(formatDateParam(selectedWeek))
const data = await workloadApi.getHeatmap(formatDateParam(selectedWeek), !showAllUsers)
setHeatmapData(data)
} catch (err) {
console.error('Failed to load workload heatmap:', err)
@@ -50,7 +54,7 @@ export default function WorkloadPage() {
} finally {
setLoading(false)
}
}, [selectedWeek])
}, [selectedWeek, showAllUsers])
useEffect(() => {
loadHeatmap()
@@ -121,6 +125,43 @@ export default function WorkloadPage() {
)}
</div>
{/* Options and Calculation Explanation */}
<div style={styles.optionsRow}>
<label style={styles.checkboxLabel}>
<input
type="checkbox"
checked={showAllUsers}
onChange={(e) => setShowAllUsers(e.target.checked)}
style={styles.checkbox}
/>
{t('options.showAllUsers')}
<span style={styles.checkboxHint}>{t('options.showAllUsersHint')}</span>
</label>
</div>
{/* Calculation Explanation */}
<div style={styles.calculationSection}>
<details style={styles.calculationDetails}>
<summary style={styles.calculationSummary}>{t('calculation.title')}</summary>
<div style={styles.calculationContent}>
<p style={styles.calculationFormula}>{t('calculation.formula')}</p>
<p style={styles.calculationText}>{t('calculation.requirements')}</p>
<ul style={styles.calculationList}>
<li>{t('calculation.req1')}</li>
<li>{t('calculation.req2')}</li>
<li>{t('calculation.req3')}</li>
<li>{t('calculation.req4')}</li>
</ul>
<p style={styles.calculationText}>{t('calculation.thresholds')}</p>
<ul style={styles.calculationList}>
<li style={{ color: '#4caf50' }}>{t('calculation.normal')}</li>
<li style={{ color: '#ff9800' }}>{t('calculation.warning')}</li>
<li style={{ color: '#f44336' }}>{t('calculation.overloaded')}</li>
</ul>
</div>
</details>
</div>
{/* Content */}
{loading ? (
<SkeletonTable rows={5} columns={6} />
@@ -309,4 +350,73 @@ const styles: { [key: string]: React.CSSProperties } = {
textTransform: 'uppercase',
letterSpacing: '0.5px',
},
optionsRow: {
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '16px',
padding: '12px 16px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
},
checkboxLabel: {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '14px',
color: '#333',
cursor: 'pointer',
},
checkbox: {
width: '16px',
height: '16px',
cursor: 'pointer',
},
checkboxHint: {
fontSize: '12px',
color: '#888',
},
calculationSection: {
marginBottom: '20px',
},
calculationDetails: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
overflow: 'hidden',
},
calculationSummary: {
padding: '12px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
color: '#333',
backgroundColor: '#f8f9fa',
borderBottom: '1px solid #eee',
},
calculationContent: {
padding: '16px',
},
calculationFormula: {
fontSize: '13px',
color: '#333',
fontWeight: 500,
margin: '0 0 12px 0',
padding: '8px 12px',
backgroundColor: '#f5f5f5',
borderRadius: '4px',
},
calculationText: {
fontSize: '13px',
color: '#666',
margin: '0 0 8px 0',
},
calculationList: {
margin: '0 0 12px 0',
paddingLeft: '20px',
fontSize: '13px',
color: '#333',
lineHeight: 1.8,
},
}

View File

@@ -25,9 +25,10 @@ export interface ProjectHealthItem {
export interface ProjectHealthSummary {
total_projects: number
healthy_count: number
at_risk_count: number
critical_count: number
healthy_count: number // health_score >= 80 (low risk)
at_risk_count: number // health_score 60-79 (medium risk)
high_risk_count: number // health_score 40-59 (high risk)
critical_count: number // health_score < 40 (critical risk)
average_health_score: number
projects_with_blockers: number
projects_delayed: number

View File

@@ -21,19 +21,12 @@ export interface WorkloadHeatmapResponse {
export interface WorkloadTask {
task_id: string
task_title: string
title: string
project_id: string
project_name: string
time_estimate: number
original_estimate: number | null
due_date: string | null
status_name: string | null
}
export interface WorkloadSummary {
allocated_hours: number
capacity_hours: number
load_percentage: number
load_level: LoadLevel
status: string | null
}
export interface UserWorkloadDetail {
@@ -41,7 +34,10 @@ export interface UserWorkloadDetail {
user_name: string
week_start: string
week_end: string
summary: WorkloadSummary
capacity_hours: number
allocated_hours: number
load_percentage: number
load_level: LoadLevel
tasks: WorkloadTask[]
}
@@ -49,9 +45,14 @@ export interface UserWorkloadDetail {
export const workloadApi = {
/**
* Get workload heatmap for all users in a specific week
* @param weekStart - Start of week (ISO date)
* @param hideEmpty - Hide users with no tasks (default: true)
*/
getHeatmap: async (weekStart?: string): Promise<WorkloadHeatmapResponse> => {
const params = weekStart ? { week_start: weekStart } : {}
getHeatmap: async (weekStart?: string, hideEmpty: boolean = true): Promise<WorkloadHeatmapResponse> => {
const params: Record<string, unknown> = { hide_empty: hideEmpty }
if (weekStart) {
params.week_start = weekStart
}
const response = await api.get<WorkloadHeatmapResponse>('/workload/heatmap', { params })
return response.data
},