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:
@@ -52,7 +52,7 @@ async def list_projects_in_space(
|
|||||||
detail="Access denied",
|
detail="Access denied",
|
||||||
)
|
)
|
||||||
|
|
||||||
projects = db.query(Project).filter(Project.space_id == space_id).all()
|
projects = db.query(Project).filter(Project.space_id == space_id, Project.is_active == True).all()
|
||||||
|
|
||||||
# Filter by project access
|
# Filter by project access
|
||||||
accessible_projects = [p for p in projects if check_project_access(current_user, p)]
|
accessible_projects = [p for p in projects if check_project_access(current_user, p)]
|
||||||
@@ -154,7 +154,7 @@ async def get_project(
|
|||||||
"""
|
"""
|
||||||
Get a project by ID.
|
Get a project by ID.
|
||||||
"""
|
"""
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
project = db.query(Project).filter(Project.id == project_id, Project.is_active == True).first()
|
||||||
|
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -202,7 +202,7 @@ async def update_project(
|
|||||||
"""
|
"""
|
||||||
Update a project.
|
Update a project.
|
||||||
"""
|
"""
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
project = db.query(Project).filter(Project.id == project_id, Project.is_active == True).first()
|
||||||
|
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -317,7 +317,7 @@ async def list_project_statuses(
|
|||||||
"""
|
"""
|
||||||
List all task statuses for a project.
|
List all task statuses for a project.
|
||||||
"""
|
"""
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
project = db.query(Project).filter(Project.id == project_id, Project.is_active == True).first()
|
||||||
|
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -406,6 +406,28 @@ async def update_task(
|
|||||||
update_data = task_data.model_dump(exclude_unset=True)
|
update_data = task_data.model_dump(exclude_unset=True)
|
||||||
custom_values_data = update_data.pop("custom_values", None)
|
custom_values_data = update_data.pop("custom_values", None)
|
||||||
|
|
||||||
|
# Track old assignee for workload cache invalidation
|
||||||
|
old_assignee_id = task.assignee_id
|
||||||
|
|
||||||
|
# Validate assignee_id if provided
|
||||||
|
if "assignee_id" in update_data and update_data["assignee_id"]:
|
||||||
|
assignee = db.query(User).filter(User.id == update_data["assignee_id"]).first()
|
||||||
|
if not assignee:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Assignee not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate status_id if provided
|
||||||
|
if "status_id" in update_data and update_data["status_id"]:
|
||||||
|
from app.models.task_status import TaskStatus
|
||||||
|
task_status = db.query(TaskStatus).filter(TaskStatus.id == update_data["status_id"]).first()
|
||||||
|
if not task_status:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Status not found",
|
||||||
|
)
|
||||||
|
|
||||||
# Get the proposed start_date and due_date for validation
|
# Get the proposed start_date and due_date for validation
|
||||||
new_start_date = update_data.get("start_date", task.start_date)
|
new_start_date = update_data.get("start_date", task.start_date)
|
||||||
new_due_date = update_data.get("due_date", task.due_date)
|
new_due_date = update_data.get("due_date", task.due_date)
|
||||||
@@ -486,6 +508,15 @@ async def update_task(
|
|||||||
if "original_estimate" in update_data and task.assignee_id:
|
if "original_estimate" in update_data and task.assignee_id:
|
||||||
invalidate_user_workload_cache(task.assignee_id)
|
invalidate_user_workload_cache(task.assignee_id)
|
||||||
|
|
||||||
|
# Invalidate workload cache if assignee changed
|
||||||
|
if "assignee_id" in update_data:
|
||||||
|
# Invalidate old assignee's cache
|
||||||
|
if old_assignee_id and old_assignee_id != task.assignee_id:
|
||||||
|
invalidate_user_workload_cache(old_assignee_id)
|
||||||
|
# Invalidate new assignee's cache
|
||||||
|
if task.assignee_id:
|
||||||
|
invalidate_user_workload_cache(task.assignee_id)
|
||||||
|
|
||||||
# Publish real-time event
|
# Publish real-time event
|
||||||
try:
|
try:
|
||||||
await publish_task_event(
|
await publish_task_event(
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ async def get_heatmap(
|
|||||||
None,
|
None,
|
||||||
description="Comma-separated list of user IDs to include"
|
description="Comma-separated list of user IDs to include"
|
||||||
),
|
),
|
||||||
|
hide_empty: bool = Query(
|
||||||
|
True,
|
||||||
|
description="Hide users with no tasks assigned for the week"
|
||||||
|
),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@@ -133,8 +137,10 @@ async def get_heatmap(
|
|||||||
|
|
||||||
week_start, week_end = get_week_bounds(week_start)
|
week_start, week_end = get_week_bounds(week_start)
|
||||||
|
|
||||||
# Try cache first
|
# Try cache first (only use cache for default hide_empty=True)
|
||||||
cached = get_cached_heatmap(week_start, department_id, accessible_user_ids)
|
cached = None
|
||||||
|
if hide_empty:
|
||||||
|
cached = get_cached_heatmap(week_start, department_id, accessible_user_ids)
|
||||||
if cached:
|
if cached:
|
||||||
return WorkloadHeatmapResponse(
|
return WorkloadHeatmapResponse(
|
||||||
week_start=week_start,
|
week_start=week_start,
|
||||||
@@ -148,10 +154,12 @@ async def get_heatmap(
|
|||||||
week_start=week_start,
|
week_start=week_start,
|
||||||
department_id=department_id,
|
department_id=department_id,
|
||||||
user_ids=accessible_user_ids,
|
user_ids=accessible_user_ids,
|
||||||
|
hide_empty=hide_empty,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cache the result
|
# Cache the result (only cache when hide_empty=True, the default)
|
||||||
set_cached_heatmap(week_start, summaries, department_id, accessible_user_ids)
|
if hide_empty:
|
||||||
|
set_cached_heatmap(week_start, summaries, department_id, accessible_user_ids)
|
||||||
|
|
||||||
return WorkloadHeatmapResponse(
|
return WorkloadHeatmapResponse(
|
||||||
week_start=week_start,
|
week_start=week_start,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class Project(Base):
|
|||||||
nullable=False
|
nullable=False
|
||||||
)
|
)
|
||||||
status = Column(String(50), default="active", nullable=False)
|
status = Column(String(50), default="active", nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
department_id = Column(String(36), ForeignKey("pjctrl_departments.id"), nullable=True)
|
department_id = Column(String(36), ForeignKey("pjctrl_departments.id"), nullable=True)
|
||||||
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
|
|||||||
@@ -54,9 +54,10 @@ class ProjectHealthWithDetails(ProjectHealthResponse):
|
|||||||
class ProjectHealthSummary(BaseModel):
|
class ProjectHealthSummary(BaseModel):
|
||||||
"""Aggregated health metrics across all projects."""
|
"""Aggregated health metrics across all projects."""
|
||||||
total_projects: int
|
total_projects: int
|
||||||
healthy_count: int # health_score >= 80
|
healthy_count: int # health_score >= 80 (low risk)
|
||||||
at_risk_count: int # health_score 50-79
|
at_risk_count: int # health_score 60-79 (medium risk)
|
||||||
critical_count: int # health_score < 50
|
high_risk_count: int # health_score 40-59 (high risk)
|
||||||
|
critical_count: int # health_score < 40 (critical risk)
|
||||||
average_health_score: float
|
average_health_score: float
|
||||||
projects_with_blockers: int
|
projects_with_blockers: int
|
||||||
projects_delayed: int
|
projects_delayed: int
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ class TaskUpdate(BaseModel):
|
|||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
priority: Optional[Priority] = None
|
priority: Optional[Priority] = None
|
||||||
|
status_id: Optional[str] = None
|
||||||
|
assignee_id: Optional[str] = None
|
||||||
original_estimate: Optional[Decimal] = None
|
original_estimate: Optional[Decimal] = None
|
||||||
time_spent: Optional[Decimal] = None
|
time_spent: Optional[Decimal] = None
|
||||||
start_date: Optional[datetime] = None
|
start_date: Optional[datetime] = None
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import List, Optional, Dict, Any
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Project, Task, TaskStatus, Blocker, ProjectHealth
|
from app.models import Project, Task, TaskStatus, Blocker, ProjectHealth, Space
|
||||||
from app.schemas.project_health import (
|
from app.schemas.project_health import (
|
||||||
RiskLevel,
|
RiskLevel,
|
||||||
ScheduleStatus,
|
ScheduleStatus,
|
||||||
@@ -83,15 +83,25 @@ def calculate_health_metrics(db: Session, project: Project) -> Dict[str, Any]:
|
|||||||
and not (task.status and task.status.is_done)
|
and not (task.status and task.status.is_done)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Count unresolved blockers
|
# Count unresolved blockers from Blocker table
|
||||||
task_ids = [t.id for t in tasks]
|
task_ids = [t.id for t in tasks]
|
||||||
blocker_count = 0
|
blocker_table_count = 0
|
||||||
if task_ids:
|
if task_ids:
|
||||||
blocker_count = db.query(Blocker).filter(
|
blocker_table_count = db.query(Blocker).filter(
|
||||||
Blocker.task_id.in_(task_ids),
|
Blocker.task_id.in_(task_ids),
|
||||||
Blocker.resolved_at.is_(None)
|
Blocker.resolved_at.is_(None)
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
|
# Also count tasks with "Blocked" status (status name contains 'block', case-insensitive)
|
||||||
|
blocked_status_count = sum(
|
||||||
|
1 for task in tasks
|
||||||
|
if task.status and task.status.name and 'block' in task.status.name.lower()
|
||||||
|
and not task.status.is_done
|
||||||
|
)
|
||||||
|
|
||||||
|
# Total blocker count = blocker table records + tasks with blocked status
|
||||||
|
blocker_count = blocker_table_count + blocked_status_count
|
||||||
|
|
||||||
# Calculate completion rate
|
# Calculate completion rate
|
||||||
completion_rate = 0.0
|
completion_rate = 0.0
|
||||||
if task_count > 0:
|
if task_count > 0:
|
||||||
@@ -234,7 +244,11 @@ def get_project_health(
|
|||||||
Returns:
|
Returns:
|
||||||
ProjectHealthWithDetails or None if project not found
|
ProjectHealthWithDetails or None if project not found
|
||||||
"""
|
"""
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
project = db.query(Project).join(Space, Project.space_id == Space.id).filter(
|
||||||
|
Project.id == project_id,
|
||||||
|
Project.is_active == True,
|
||||||
|
Space.is_active == True
|
||||||
|
).first()
|
||||||
if not project:
|
if not project:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -261,7 +275,10 @@ def get_all_projects_health(
|
|||||||
Returns:
|
Returns:
|
||||||
ProjectHealthDashboardResponse with projects list and summary
|
ProjectHealthDashboardResponse with projects list and summary
|
||||||
"""
|
"""
|
||||||
query = db.query(Project)
|
query = db.query(Project).join(Space, Project.space_id == Space.id).filter(
|
||||||
|
Project.is_active == True,
|
||||||
|
Space.is_active == True
|
||||||
|
)
|
||||||
if status_filter:
|
if status_filter:
|
||||||
query = query.filter(Project.status == status_filter)
|
query = query.filter(Project.status == status_filter)
|
||||||
|
|
||||||
@@ -314,12 +331,21 @@ def _build_health_with_details(
|
|||||||
def _calculate_summary(
|
def _calculate_summary(
|
||||||
projects_health: List[ProjectHealthWithDetails]
|
projects_health: List[ProjectHealthWithDetails]
|
||||||
) -> ProjectHealthSummary:
|
) -> ProjectHealthSummary:
|
||||||
"""Calculate summary statistics for health dashboard."""
|
"""Calculate summary statistics for health dashboard.
|
||||||
|
|
||||||
|
Thresholds match _determine_risk_level():
|
||||||
|
- healthy (low risk): >= 80
|
||||||
|
- at_risk (medium risk): 60-79
|
||||||
|
- high_risk (high risk): 40-59
|
||||||
|
- critical (critical risk): < 40
|
||||||
|
"""
|
||||||
total_projects = len(projects_health)
|
total_projects = len(projects_health)
|
||||||
|
|
||||||
healthy_count = sum(1 for p in projects_health if p.health_score >= 80)
|
# Use consistent thresholds with risk_level calculation
|
||||||
at_risk_count = sum(1 for p in projects_health if 50 <= p.health_score < 80)
|
healthy_count = sum(1 for p in projects_health if p.health_score >= RISK_LOW_THRESHOLD)
|
||||||
critical_count = sum(1 for p in projects_health if p.health_score < 50)
|
at_risk_count = sum(1 for p in projects_health if RISK_MEDIUM_THRESHOLD <= p.health_score < RISK_LOW_THRESHOLD)
|
||||||
|
high_risk_count = sum(1 for p in projects_health if RISK_HIGH_THRESHOLD <= p.health_score < RISK_MEDIUM_THRESHOLD)
|
||||||
|
critical_count = sum(1 for p in projects_health if p.health_score < RISK_HIGH_THRESHOLD)
|
||||||
|
|
||||||
average_health_score = 0.0
|
average_health_score = 0.0
|
||||||
if total_projects > 0:
|
if total_projects > 0:
|
||||||
@@ -335,6 +361,7 @@ def _calculate_summary(
|
|||||||
total_projects=total_projects,
|
total_projects=total_projects,
|
||||||
healthy_count=healthy_count,
|
healthy_count=healthy_count,
|
||||||
at_risk_count=at_risk_count,
|
at_risk_count=at_risk_count,
|
||||||
|
high_risk_count=high_risk_count,
|
||||||
critical_count=critical_count,
|
critical_count=critical_count,
|
||||||
average_health_score=round(average_health_score, 1),
|
average_health_score=round(average_health_score, 1),
|
||||||
projects_with_blockers=projects_with_blockers,
|
projects_with_blockers=projects_with_blockers,
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ def get_workload_heatmap(
|
|||||||
week_start: Optional[date] = None,
|
week_start: Optional[date] = None,
|
||||||
department_id: Optional[str] = None,
|
department_id: Optional[str] = None,
|
||||||
user_ids: Optional[List[str]] = None,
|
user_ids: Optional[List[str]] = None,
|
||||||
|
hide_empty: bool = True,
|
||||||
) -> List[UserWorkloadSummary]:
|
) -> List[UserWorkloadSummary]:
|
||||||
"""
|
"""
|
||||||
Get workload heatmap for multiple users.
|
Get workload heatmap for multiple users.
|
||||||
@@ -180,6 +181,7 @@ def get_workload_heatmap(
|
|||||||
week_start: Start of week (defaults to current week)
|
week_start: Start of week (defaults to current week)
|
||||||
department_id: Filter by department
|
department_id: Filter by department
|
||||||
user_ids: Filter by specific user IDs
|
user_ids: Filter by specific user IDs
|
||||||
|
hide_empty: If True, exclude users with no tasks (default: True)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of UserWorkloadSummary objects
|
List of UserWorkloadSummary objects
|
||||||
@@ -260,6 +262,10 @@ def get_workload_heatmap(
|
|||||||
)
|
)
|
||||||
results.append(summary)
|
results.append(summary)
|
||||||
|
|
||||||
|
# Filter out users with no tasks if hide_empty is True
|
||||||
|
if hide_empty:
|
||||||
|
results = [r for r in results if r.task_count > 0]
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""add is_active to projects
|
||||||
|
|
||||||
|
Revision ID: a0a0f2710e01
|
||||||
|
Revises: 013
|
||||||
|
Create Date: 2026-01-09 21:51:11.802999
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'a0a0f2710e01'
|
||||||
|
down_revision: Union[str, None] = '013'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('pjctrl_projects', sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('pjctrl_projects', 'is_active')
|
||||||
@@ -92,7 +92,15 @@
|
|||||||
"title": "Notifications",
|
"title": "Notifications",
|
||||||
"markAllRead": "Mark all as read",
|
"markAllRead": "Mark all as read",
|
||||||
"noNotifications": "No notifications",
|
"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": {
|
"pagination": {
|
||||||
"page": "Page {{page}}",
|
"page": "Page {{page}}",
|
||||||
|
|||||||
@@ -11,11 +11,24 @@
|
|||||||
"totalProjects": "Total Projects",
|
"totalProjects": "Total Projects",
|
||||||
"healthy": "Healthy",
|
"healthy": "Healthy",
|
||||||
"atRisk": "At Risk",
|
"atRisk": "At Risk",
|
||||||
|
"highRisk": "High Risk",
|
||||||
"critical": "Critical",
|
"critical": "Critical",
|
||||||
"avgHealth": "Avg. Health",
|
"avgHealth": "Avg. Health",
|
||||||
"withBlockers": "With Blockers",
|
"withBlockers": "With Blockers",
|
||||||
"delayed": "Delayed"
|
"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": {
|
"sort": {
|
||||||
"label": "Sort by",
|
"label": "Sort by",
|
||||||
"riskHigh": "Risk: High to Low",
|
"riskHigh": "Risk: High to Low",
|
||||||
@@ -36,7 +49,29 @@
|
|||||||
"delayed": "Delayed",
|
"delayed": "Delayed",
|
||||||
"ahead": "Ahead",
|
"ahead": "Ahead",
|
||||||
"overBudget": "Over Budget",
|
"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": {
|
"indicators": {
|
||||||
"title": "Health Indicators",
|
"title": "Health Indicators",
|
||||||
|
|||||||
@@ -2,6 +2,21 @@
|
|||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"projectSettings": "Project Settings",
|
"projectSettings": "Project Settings",
|
||||||
"backToTasks": "Back to Tasks",
|
"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": {
|
"tabs": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"members": "Members",
|
"members": "Members",
|
||||||
@@ -37,16 +52,58 @@
|
|||||||
"add": "Add Field",
|
"add": "Add Field",
|
||||||
"edit": "Edit Field",
|
"edit": "Edit Field",
|
||||||
"delete": "Delete Field",
|
"delete": "Delete Field",
|
||||||
|
"create": "Create Field",
|
||||||
"fieldName": "Field Name",
|
"fieldName": "Field Name",
|
||||||
|
"fieldNamePlaceholder": "e.g., Story Points, Sprint Number",
|
||||||
"fieldType": "Field Type",
|
"fieldType": "Field Type",
|
||||||
"required": "Required",
|
"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": {
|
"types": {
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
|
"textDesc": "Single line text input",
|
||||||
"number": "Number",
|
"number": "Number",
|
||||||
|
"numberDesc": "Numeric value",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"select": "Dropdown",
|
"dateDesc": "Date picker",
|
||||||
"multiSelect": "Multi-select",
|
"dropdown": "Dropdown",
|
||||||
"checkbox": "Checkbox"
|
"dropdownDesc": "Select from predefined options",
|
||||||
|
"person": "Person",
|
||||||
|
"personDesc": "User assignment",
|
||||||
|
"formula": "Formula",
|
||||||
|
"formulaDesc": "Calculated from other fields"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "Field name is required"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
|
|||||||
@@ -30,13 +30,43 @@
|
|||||||
"overloaded": "Overloaded",
|
"overloaded": "Overloaded",
|
||||||
"underutilized": "Underutilized"
|
"underutilized": "Underutilized"
|
||||||
},
|
},
|
||||||
|
"table": {
|
||||||
|
"member": "Team Member",
|
||||||
|
"department": "Department",
|
||||||
|
"allocated": "Allocated",
|
||||||
|
"capacity": "Capacity",
|
||||||
|
"load": "Load",
|
||||||
|
"status": "Status"
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"balanced": "Balanced",
|
"balanced": "Balanced",
|
||||||
|
"normal": "Normal",
|
||||||
|
"warning": "Warning",
|
||||||
"overloaded": "Overloaded",
|
"overloaded": "Overloaded",
|
||||||
|
"unavailable": "Unavailable",
|
||||||
"underutilized": "Underutilized"
|
"underutilized": "Underutilized"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "No Workload Data",
|
"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%"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,15 @@
|
|||||||
"title": "通知",
|
"title": "通知",
|
||||||
"markAllRead": "全部標為已讀",
|
"markAllRead": "全部標為已讀",
|
||||||
"noNotifications": "沒有通知",
|
"noNotifications": "沒有通知",
|
||||||
"viewAll": "查看全部"
|
"empty": "沒有通知",
|
||||||
|
"viewAll": "查看全部",
|
||||||
|
"refresh": "重新整理",
|
||||||
|
"time": {
|
||||||
|
"justNow": "剛剛",
|
||||||
|
"minutesAgo": "{{count}} 分鐘前",
|
||||||
|
"hoursAgo": "{{count}} 小時前",
|
||||||
|
"daysAgo": "{{count}} 天前"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"page": "第 {{page}} 頁",
|
"page": "第 {{page}} 頁",
|
||||||
|
|||||||
@@ -11,11 +11,24 @@
|
|||||||
"totalProjects": "專案總數",
|
"totalProjects": "專案總數",
|
||||||
"healthy": "健康",
|
"healthy": "健康",
|
||||||
"atRisk": "風險中",
|
"atRisk": "風險中",
|
||||||
|
"highRisk": "高風險",
|
||||||
"critical": "危急",
|
"critical": "危急",
|
||||||
"avgHealth": "平均健康度",
|
"avgHealth": "平均健康度",
|
||||||
"withBlockers": "有阻擋問題",
|
"withBlockers": "有阻擋問題",
|
||||||
"delayed": "延遲"
|
"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": {
|
"sort": {
|
||||||
"label": "排序方式",
|
"label": "排序方式",
|
||||||
"riskHigh": "風險:高到低",
|
"riskHigh": "風險:高到低",
|
||||||
@@ -36,7 +49,29 @@
|
|||||||
"delayed": "延遲",
|
"delayed": "延遲",
|
||||||
"ahead": "超前",
|
"ahead": "超前",
|
||||||
"overBudget": "超支",
|
"overBudget": "超支",
|
||||||
"underBudget": "低於預算"
|
"underBudget": "低於預算",
|
||||||
|
"atRisk": "有風險"
|
||||||
|
},
|
||||||
|
"resourceStatus": {
|
||||||
|
"adequate": "充足",
|
||||||
|
"constrained": "受限",
|
||||||
|
"overloaded": "超載"
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"health": "健康度",
|
||||||
|
"schedule": "進度",
|
||||||
|
"resources": "資源",
|
||||||
|
"owner": "負責人",
|
||||||
|
"taskProgress": "任務進度",
|
||||||
|
"blockers": "阻擋問題",
|
||||||
|
"overdue": "逾期",
|
||||||
|
"complete": "完成"
|
||||||
|
},
|
||||||
|
"riskLevel": {
|
||||||
|
"low": "低風險",
|
||||||
|
"medium": "中風險",
|
||||||
|
"high": "高風險",
|
||||||
|
"critical": "危急"
|
||||||
},
|
},
|
||||||
"indicators": {
|
"indicators": {
|
||||||
"title": "健康指標",
|
"title": "健康指標",
|
||||||
|
|||||||
@@ -2,6 +2,21 @@
|
|||||||
"title": "設定",
|
"title": "設定",
|
||||||
"projectSettings": "專案設定",
|
"projectSettings": "專案設定",
|
||||||
"backToTasks": "返回任務",
|
"backToTasks": "返回任務",
|
||||||
|
"mySettings": {
|
||||||
|
"title": "個人設定",
|
||||||
|
"profile": "個人資訊",
|
||||||
|
"email": "電子郵件",
|
||||||
|
"department": "部門",
|
||||||
|
"role": "角色",
|
||||||
|
"workloadSettings": "工作負載設定",
|
||||||
|
"capacityDescription": "設定您每週可用的工作時數,用於計算工作負載百分比。",
|
||||||
|
"weeklyCapacity": "每週容量",
|
||||||
|
"hoursPerWeek": "小時/週",
|
||||||
|
"capacityHelp": "建議值:40 小時(標準工時)。最大值:168 小時(一週總時數)。",
|
||||||
|
"capacitySaved": "容量設定已儲存",
|
||||||
|
"capacityError": "儲存容量設定失敗",
|
||||||
|
"capacityInvalid": "請輸入有效的時數(0-168)"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"general": "一般",
|
"general": "一般",
|
||||||
"members": "成員",
|
"members": "成員",
|
||||||
@@ -37,16 +52,58 @@
|
|||||||
"add": "新增欄位",
|
"add": "新增欄位",
|
||||||
"edit": "編輯欄位",
|
"edit": "編輯欄位",
|
||||||
"delete": "刪除欄位",
|
"delete": "刪除欄位",
|
||||||
|
"create": "建立欄位",
|
||||||
"fieldName": "欄位名稱",
|
"fieldName": "欄位名稱",
|
||||||
|
"fieldNamePlaceholder": "例如:故事點數、衝刺編號",
|
||||||
"fieldType": "欄位類型",
|
"fieldType": "欄位類型",
|
||||||
"required": "必填",
|
"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": {
|
"types": {
|
||||||
"text": "文字",
|
"text": "文字",
|
||||||
|
"textDesc": "單行文字輸入",
|
||||||
"number": "數字",
|
"number": "數字",
|
||||||
|
"numberDesc": "數值",
|
||||||
"date": "日期",
|
"date": "日期",
|
||||||
"select": "下拉選單",
|
"dateDesc": "日期選擇器",
|
||||||
"multiSelect": "多選",
|
"dropdown": "下拉選單",
|
||||||
"checkbox": "核取方塊"
|
"dropdownDesc": "從預設選項中選擇",
|
||||||
|
"person": "人員",
|
||||||
|
"personDesc": "使用者指派",
|
||||||
|
"formula": "公式",
|
||||||
|
"formulaDesc": "從其他欄位計算"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "欄位名稱為必填"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
|
|||||||
@@ -30,13 +30,43 @@
|
|||||||
"overloaded": "超載",
|
"overloaded": "超載",
|
||||||
"underutilized": "低使用率"
|
"underutilized": "低使用率"
|
||||||
},
|
},
|
||||||
|
"table": {
|
||||||
|
"member": "團隊成員",
|
||||||
|
"department": "部門",
|
||||||
|
"allocated": "已分配",
|
||||||
|
"capacity": "容量",
|
||||||
|
"load": "負載",
|
||||||
|
"status": "狀態"
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"balanced": "平衡",
|
"balanced": "平衡",
|
||||||
|
"normal": "正常",
|
||||||
|
"warning": "警告",
|
||||||
"overloaded": "超載",
|
"overloaded": "超載",
|
||||||
|
"unavailable": "無法使用",
|
||||||
"underutilized": "低使用率"
|
"underutilized": "低使用率"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "沒有工作負載資料",
|
"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%"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Spaces from './pages/Spaces'
|
|||||||
import Projects from './pages/Projects'
|
import Projects from './pages/Projects'
|
||||||
import Tasks from './pages/Tasks'
|
import Tasks from './pages/Tasks'
|
||||||
import ProjectSettings from './pages/ProjectSettings'
|
import ProjectSettings from './pages/ProjectSettings'
|
||||||
|
import MySettings from './pages/MySettings'
|
||||||
import AuditPage from './pages/AuditPage'
|
import AuditPage from './pages/AuditPage'
|
||||||
import WorkloadPage from './pages/WorkloadPage'
|
import WorkloadPage from './pages/WorkloadPage'
|
||||||
import ProjectHealthPage from './pages/ProjectHealthPage'
|
import ProjectHealthPage from './pages/ProjectHealthPage'
|
||||||
@@ -111,6 +112,16 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/my-settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<MySettings />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface Task {
|
|||||||
start_date: string | null
|
start_date: string | null
|
||||||
time_estimate: number | null
|
time_estimate: number | null
|
||||||
subtask_count: number
|
subtask_count: number
|
||||||
|
parent_task_id: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaskStatus {
|
interface TaskStatus {
|
||||||
@@ -249,7 +250,11 @@ export function CalendarView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Optimistic update - event is already moved in the calendar
|
// 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 {
|
try {
|
||||||
await api.patch(`/tasks/${task.id}`, {
|
await api.patch(`/tasks/${task.id}`, {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
customFieldsApi,
|
customFieldsApi,
|
||||||
CustomField,
|
CustomField,
|
||||||
@@ -14,21 +15,22 @@ interface CustomFieldEditorProps {
|
|||||||
onSave: () => void
|
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({
|
export function CustomFieldEditor({
|
||||||
projectId,
|
projectId,
|
||||||
field,
|
field,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
}: CustomFieldEditorProps) {
|
}: 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 isEditing = field !== null
|
||||||
|
|
||||||
const [name, setName] = useState(field?.name || '')
|
const [name, setName] = useState(field?.name || '')
|
||||||
@@ -98,20 +100,20 @@ export function CustomFieldEditor({
|
|||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError('Field name is required')
|
setError(t('customFields.validation.nameRequired'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fieldType === 'dropdown') {
|
if (fieldType === 'dropdown') {
|
||||||
const validOptions = options.filter((opt) => opt.trim())
|
const validOptions = options.filter((opt) => opt.trim())
|
||||||
if (validOptions.length === 0) {
|
if (validOptions.length === 0) {
|
||||||
setError('At least one option is required for dropdown fields')
|
setError(t('customFields.optionRequired'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fieldType === 'formula' && !formula.trim()) {
|
if (fieldType === 'formula' && !formula.trim()) {
|
||||||
setError('Formula expression is required')
|
setError(t('customFields.formulaRequired'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +166,7 @@ export function CustomFieldEditor({
|
|||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
|
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
|
||||||
'Failed to save field'
|
t('customFields.saveError')
|
||||||
setError(errorMessage)
|
setError(errorMessage)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
@@ -184,9 +186,9 @@ export function CustomFieldEditor({
|
|||||||
<div style={styles.modal}>
|
<div style={styles.modal}>
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
<h2 id="custom-field-editor-title" style={styles.title}>
|
<h2 id="custom-field-editor-title" style={styles.title}>
|
||||||
{isEditing ? 'Edit Custom Field' : 'Create Custom Field'}
|
{isEditing ? t('customFields.edit') : t('customFields.create')}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">
|
<button onClick={onClose} style={styles.closeButton} aria-label={t('common:buttons.close')}>
|
||||||
X
|
X
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,12 +198,12 @@ export function CustomFieldEditor({
|
|||||||
|
|
||||||
{/* Field Name */}
|
{/* Field Name */}
|
||||||
<div style={styles.formGroup}>
|
<div style={styles.formGroup}>
|
||||||
<label style={styles.label}>Field Name *</label>
|
<label style={styles.label}>{t('customFields.fieldName')} *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g., Story Points, Sprint Number"
|
placeholder={t('customFields.fieldNamePlaceholder')}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
/>
|
/>
|
||||||
@@ -210,7 +212,7 @@ export function CustomFieldEditor({
|
|||||||
{/* Field Type - only show for create mode */}
|
{/* Field Type - only show for create mode */}
|
||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<div style={styles.formGroup}>
|
<div style={styles.formGroup}>
|
||||||
<label style={styles.label}>Field Type *</label>
|
<label style={styles.label}>{t('customFields.fieldType')} *</label>
|
||||||
<div style={styles.typeGrid}>
|
<div style={styles.typeGrid}>
|
||||||
{FIELD_TYPES.map((type) => (
|
{FIELD_TYPES.map((type) => (
|
||||||
<div
|
<div
|
||||||
@@ -232,10 +234,10 @@ export function CustomFieldEditor({
|
|||||||
{/* Show current type info for edit mode */}
|
{/* Show current type info for edit mode */}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div style={styles.formGroup}>
|
<div style={styles.formGroup}>
|
||||||
<label style={styles.label}>Field Type</label>
|
<label style={styles.label}>{t('customFields.fieldType')}</label>
|
||||||
<div style={styles.typeDisplay}>
|
<div style={styles.typeDisplay}>
|
||||||
{FIELD_TYPES.find((t) => t.value === fieldType)?.label}
|
{FIELD_TYPES.find((ft) => ft.value === fieldType)?.label}
|
||||||
<span style={styles.typeNote}>(cannot be changed)</span>
|
<span style={styles.typeNote}>({t('customFields.cannotChangeType')})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -243,7 +245,7 @@ export function CustomFieldEditor({
|
|||||||
{/* Dropdown Options */}
|
{/* Dropdown Options */}
|
||||||
{fieldType === 'dropdown' && (
|
{fieldType === 'dropdown' && (
|
||||||
<div style={styles.formGroup}>
|
<div style={styles.formGroup}>
|
||||||
<label style={styles.label}>Options *</label>
|
<label style={styles.label}>{t('customFields.options')} *</label>
|
||||||
<div style={styles.optionsList}>
|
<div style={styles.optionsList}>
|
||||||
{options.map((option, index) => (
|
{options.map((option, index) => (
|
||||||
<div key={index} style={styles.optionRow}>
|
<div key={index} style={styles.optionRow}>
|
||||||
@@ -251,14 +253,14 @@ export function CustomFieldEditor({
|
|||||||
type="text"
|
type="text"
|
||||||
value={option}
|
value={option}
|
||||||
onChange={(e) => handleOptionChange(index, e.target.value)}
|
onChange={(e) => handleOptionChange(index, e.target.value)}
|
||||||
placeholder={`Option ${index + 1}`}
|
placeholder={t('customFields.optionPlaceholder', { index: index + 1 })}
|
||||||
style={styles.optionInput}
|
style={styles.optionInput}
|
||||||
/>
|
/>
|
||||||
{options.length > 1 && (
|
{options.length > 1 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveOption(index)}
|
onClick={() => handleRemoveOption(index)}
|
||||||
style={styles.removeOptionButton}
|
style={styles.removeOptionButton}
|
||||||
aria-label="Remove option"
|
aria-label={t('common:buttons.remove')}
|
||||||
>
|
>
|
||||||
X
|
X
|
||||||
</button>
|
</button>
|
||||||
@@ -267,7 +269,7 @@ export function CustomFieldEditor({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleAddOption} style={styles.addOptionButton}>
|
<button onClick={handleAddOption} style={styles.addOptionButton}>
|
||||||
+ Add Option
|
+ {t('customFields.addOption')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -275,28 +277,28 @@ export function CustomFieldEditor({
|
|||||||
{/* Formula Expression */}
|
{/* Formula Expression */}
|
||||||
{fieldType === 'formula' && (
|
{fieldType === 'formula' && (
|
||||||
<div style={styles.formGroup}>
|
<div style={styles.formGroup}>
|
||||||
<label style={styles.label}>Formula Expression *</label>
|
<label style={styles.label}>{t('customFields.formula')} *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formula}
|
value={formula}
|
||||||
onChange={(e) => setFormula(e.target.value)}
|
onChange={(e) => setFormula(e.target.value)}
|
||||||
placeholder="e.g., {time_spent} / {original_estimate} * 100"
|
placeholder={t('customFields.formulaPlaceholder')}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
/>
|
/>
|
||||||
<div style={styles.formulaHelp}>
|
<div style={styles.formulaHelp}>
|
||||||
<p>Use curly braces to reference other fields:</p>
|
<p>{t('customFields.formulaHelp.intro')}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<code>{'{field_name}'}</code> - Reference a custom number field
|
<code>{'{field_name}'}</code> - {t('customFields.formulaHelp.customField')}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<code>{'{original_estimate}'}</code> - Task time estimate
|
<code>{'{original_estimate}'}</code> - {t('customFields.formulaHelp.estimate')}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<code>{'{time_spent}'}</code> - Logged time
|
<code>{'{time_spent}'}</code> - {t('customFields.formulaHelp.timeSpent')}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Supported operators: +, -, *, /</p>
|
<p>{t('customFields.formulaHelp.operators')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -310,24 +312,24 @@ export function CustomFieldEditor({
|
|||||||
onChange={(e) => setIsRequired(e.target.checked)}
|
onChange={(e) => setIsRequired(e.target.checked)}
|
||||||
style={styles.checkbox}
|
style={styles.checkbox}
|
||||||
/>
|
/>
|
||||||
Required field
|
{t('customFields.requiredField')}
|
||||||
</label>
|
</label>
|
||||||
<div style={styles.checkboxHelp}>
|
<div style={styles.checkboxHelp}>
|
||||||
Tasks cannot be created or updated without filling in required fields.
|
{t('customFields.requiredHelp')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.footer}>
|
<div style={styles.footer}>
|
||||||
<button onClick={onClose} style={styles.cancelButton} disabled={saving}>
|
<button onClick={onClose} style={styles.cancelButton} disabled={saving}>
|
||||||
Cancel
|
{t('common:buttons.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
style={styles.saveButton}
|
style={styles.saveButton}
|
||||||
disabled={saving || !name.trim()}
|
disabled={saving || !name.trim()}
|
||||||
>
|
>
|
||||||
{saving ? 'Saving...' : isEditing ? 'Save Changes' : 'Create Field'}
|
{saving ? t('customFields.saving') : isEditing ? t('customFields.saveChanges') : t('customFields.create')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { customFieldsApi, CustomField, FieldType } from '../services/customFields'
|
import { customFieldsApi, CustomField, FieldType } from '../services/customFields'
|
||||||
import { CustomFieldEditor } from './CustomFieldEditor'
|
import { CustomFieldEditor } from './CustomFieldEditor'
|
||||||
import { useToast } from '../contexts/ToastContext'
|
import { useToast } from '../contexts/ToastContext'
|
||||||
@@ -7,16 +8,8 @@ interface CustomFieldListProps {
|
|||||||
projectId: string
|
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) {
|
export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
||||||
|
const { t } = useTranslation('settings')
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const [fields, setFields] = useState<CustomField[]>([])
|
const [fields, setFields] = useState<CustomField[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -26,6 +19,15 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
|||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||||
const [deleting, setDeleting] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
loadFields()
|
loadFields()
|
||||||
}, [projectId])
|
}, [projectId])
|
||||||
@@ -38,7 +40,7 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
|||||||
setFields(response.fields)
|
setFields(response.fields)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load custom fields:', err)
|
console.error('Failed to load custom fields:', err)
|
||||||
setError('Failed to load custom fields')
|
setError(t('customFields.loadError'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -81,13 +83,13 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
|||||||
await customFieldsApi.deleteCustomField(deleteConfirm)
|
await customFieldsApi.deleteCustomField(deleteConfirm)
|
||||||
setDeleteConfirm(null)
|
setDeleteConfirm(null)
|
||||||
loadFields()
|
loadFields()
|
||||||
showToast('Custom field deleted successfully', 'success')
|
showToast(t('customFields.deleted'), 'success')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
: (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
|
: (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
|
||||||
'Failed to delete field'
|
t('customFields.deleteError')
|
||||||
showToast(errorMessage, 'error')
|
showToast(errorMessage, 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false)
|
setDeleting(false)
|
||||||
@@ -95,7 +97,7 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div style={styles.loading}>Loading custom fields...</div>
|
return <div style={styles.loading}>{t('customFields.loading')}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -103,7 +105,7 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
|||||||
<div style={styles.error}>
|
<div style={styles.error}>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<button onClick={loadFields} style={styles.retryButton}>
|
<button onClick={loadFields} style={styles.retryButton}>
|
||||||
Retry
|
{t('customFields.retry')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -112,23 +114,18 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
|||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<div style={styles.header}>
|
<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}>
|
<button onClick={handleCreate} style={styles.addButton}>
|
||||||
+ Add Field
|
+ {t('customFields.add')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={styles.description}>
|
<p style={styles.description}>{t('customFields.description')}</p>
|
||||||
Custom fields allow you to add additional data to tasks. You can create up to 20
|
|
||||||
fields per project.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{fields.length === 0 ? (
|
{fields.length === 0 ? (
|
||||||
<div style={styles.emptyState}>
|
<div style={styles.emptyState}>
|
||||||
<p>No custom fields defined yet.</p>
|
<p>{t('customFields.empty')}</p>
|
||||||
<p style={styles.emptyHint}>
|
<p style={styles.emptyHint}>{t('customFields.emptyHint')}</p>
|
||||||
Click "Add Field" to create your first custom field.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={styles.fieldList}>
|
<div style={styles.fieldList}>
|
||||||
@@ -137,15 +134,15 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
|||||||
<div style={styles.fieldInfo}>
|
<div style={styles.fieldInfo}>
|
||||||
<div style={styles.fieldName}>
|
<div style={styles.fieldName}>
|
||||||
{field.name}
|
{field.name}
|
||||||
{field.is_required && <span style={styles.requiredBadge}>Required</span>}
|
{field.is_required && (
|
||||||
|
<span style={styles.requiredBadge}>{t('customFields.required')}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.fieldMeta}>
|
<div style={styles.fieldMeta}>
|
||||||
<span style={styles.fieldType}>
|
<span style={styles.fieldType}>{fieldTypeLabels[field.field_type]}</span>
|
||||||
{FIELD_TYPE_LABELS[field.field_type]}
|
|
||||||
</span>
|
|
||||||
{field.field_type === 'dropdown' && field.options && (
|
{field.field_type === 'dropdown' && field.options && (
|
||||||
<span style={styles.optionCount}>
|
<span style={styles.optionCount}>
|
||||||
{field.options.length} option{field.options.length !== 1 ? 's' : ''}
|
{field.options.length} {t('customFields.options').toLowerCase()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{field.field_type === 'formula' && field.formula && (
|
{field.field_type === 'formula' && field.formula && (
|
||||||
@@ -157,16 +154,16 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(field)}
|
onClick={() => handleEdit(field)}
|
||||||
style={styles.editButton}
|
style={styles.editButton}
|
||||||
aria-label={`Edit ${field.name}`}
|
aria-label={`${t('customFields.edit')} ${field.name}`}
|
||||||
>
|
>
|
||||||
Edit
|
{t('common:buttons.edit')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteClick(field.id)}
|
onClick={() => handleDeleteClick(field.id)}
|
||||||
style={styles.deleteButton}
|
style={styles.deleteButton}
|
||||||
aria-label={`Delete ${field.name}`}
|
aria-label={`${t('customFields.delete')} ${field.name}`}
|
||||||
>
|
>
|
||||||
Delete
|
{t('common:buttons.delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,25 +185,22 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
|||||||
{deleteConfirm && (
|
{deleteConfirm && (
|
||||||
<div style={styles.modalOverlay}>
|
<div style={styles.modalOverlay}>
|
||||||
<div style={styles.confirmModal}>
|
<div style={styles.confirmModal}>
|
||||||
<h3 style={styles.confirmTitle}>Delete Custom Field?</h3>
|
<h3 style={styles.confirmTitle}>{t('customFields.deleteConfirmTitle')}</h3>
|
||||||
<p style={styles.confirmMessage}>
|
<p style={styles.confirmMessage}>{t('customFields.deleteConfirmMessage')}</p>
|
||||||
This will permanently delete this field and all stored values for all tasks.
|
|
||||||
This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
<div style={styles.confirmActions}>
|
<div style={styles.confirmActions}>
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteCancel}
|
onClick={handleDeleteCancel}
|
||||||
style={styles.cancelButton}
|
style={styles.cancelButton}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common:buttons.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteConfirm}
|
onClick={handleDeleteConfirm}
|
||||||
style={styles.confirmDeleteButton}
|
style={styles.confirmDeleteButton}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
{deleting ? 'Deleting...' : 'Delete'}
|
{deleting ? t('customFields.deleting') : t('common:buttons.delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +271,7 @@ const styles: Record<string, React.CSSProperties> = {
|
|||||||
},
|
},
|
||||||
emptyHint: {
|
emptyHint: {
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
color: '#767676', // WCAG AA compliant
|
color: '#767676',
|
||||||
marginTop: '8px',
|
marginTop: '8px',
|
||||||
},
|
},
|
||||||
fieldList: {
|
fieldList: {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface Task {
|
|||||||
start_date: string | null
|
start_date: string | null
|
||||||
time_estimate: number | null
|
time_estimate: number | null
|
||||||
subtask_count: number
|
subtask_count: number
|
||||||
|
parent_task_id: string | null
|
||||||
progress?: number
|
progress?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,9 +246,15 @@ export function GanttChart({
|
|||||||
setError(null)
|
setError(null)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
// Format dates
|
// Format dates in local timezone (not UTC)
|
||||||
const startDate = start.toISOString().split('T')[0]
|
const formatLocalDate = (d: Date) => {
|
||||||
const dueDate = end.toISOString().split('T')[0]
|
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 {
|
try {
|
||||||
await api.patch(`/tasks/${taskId}`, {
|
await api.patch(`/tasks/${taskId}`, {
|
||||||
@@ -725,7 +732,7 @@ const styles: Record<string, React.CSSProperties> = {
|
|||||||
},
|
},
|
||||||
viewModeButtonActive: {
|
viewModeButtonActive: {
|
||||||
backgroundColor: '#0066cc',
|
backgroundColor: '#0066cc',
|
||||||
borderColor: '#0066cc',
|
border: '1px solid #0066cc',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
},
|
},
|
||||||
loadingIndicator: {
|
loadingIndicator: {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface Task {
|
|||||||
start_date: string | null
|
start_date: string | null
|
||||||
time_estimate: number | null
|
time_estimate: number | null
|
||||||
subtask_count: number
|
subtask_count: number
|
||||||
|
parent_task_id: string | null
|
||||||
custom_values?: CustomValueResponse[]
|
custom_values?: CustomValueResponse[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,13 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
<div style={styles.headerRight}>
|
<div style={styles.headerRight}>
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<NotificationBell />
|
<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 && (
|
{user?.is_system_admin && (
|
||||||
<span style={styles.badge}>Admin</span>
|
<span style={styles.badge}>Admin</span>
|
||||||
)}
|
)}
|
||||||
@@ -114,9 +120,15 @@ const styles: { [key: string]: React.CSSProperties } = {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '12px',
|
gap: '12px',
|
||||||
},
|
},
|
||||||
userName: {
|
userNameButton: {
|
||||||
color: '#666',
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#0066cc',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
textDecoration: 'underline',
|
||||||
},
|
},
|
||||||
badge: {
|
badge: {
|
||||||
backgroundColor: '#0066cc',
|
backgroundColor: '#0066cc',
|
||||||
|
|||||||
@@ -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 { useNotifications } from '../contexts/NotificationContext'
|
||||||
import { SkeletonList } from './Skeleton'
|
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() {
|
export function NotificationBell() {
|
||||||
|
const { t } = useTranslation('common')
|
||||||
const { notifications, unreadCount, loading, fetchNotifications, markAsRead, markAllAsRead } =
|
const { notifications, unreadCount, loading, fetchNotifications, markAsRead, markAllAsRead } =
|
||||||
useNotifications()
|
useNotifications()
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||||||
|
const [buttonHovered, setButtonHovered] = useState(false)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
@@ -51,22 +202,27 @@ export function NotificationBell() {
|
|||||||
const diffHours = Math.floor(diffMins / 60)
|
const diffHours = Math.floor(diffMins / 60)
|
||||||
const diffDays = Math.floor(diffHours / 24)
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
|
||||||
if (diffMins < 1) return 'Just now'
|
if (diffMins < 1) return t('notifications.time.justNow')
|
||||||
if (diffMins < 60) return `${diffMins}m ago`
|
if (diffMins < 60) return t('notifications.time.minutesAgo', { count: diffMins })
|
||||||
if (diffHours < 24) return `${diffHours}h ago`
|
if (diffHours < 24) return t('notifications.time.hoursAgo', { count: diffHours })
|
||||||
if (diffDays < 7) return `${diffDays}d ago`
|
if (diffDays < 7) return t('notifications.time.daysAgo', { count: diffDays })
|
||||||
return date.toLocaleDateString()
|
return date.toLocaleDateString()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div style={styles.container} ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
className="relative p-2 text-gray-600 hover:text-gray-900 focus:outline-none"
|
onMouseEnter={() => setButtonHovered(true)}
|
||||||
aria-label="Notifications"
|
onMouseLeave={() => setButtonHovered(false)}
|
||||||
|
style={{
|
||||||
|
...styles.button,
|
||||||
|
...(buttonHovered ? styles.buttonHover : {}),
|
||||||
|
}}
|
||||||
|
aria-label={t('notifications.title')}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-6 h-6"
|
style={styles.icon}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -79,61 +235,65 @@ export function NotificationBell() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{unreadCount > 0 && (
|
{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}
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border z-50">
|
<div style={styles.dropdown}>
|
||||||
<div className="p-3 border-b flex justify-between items-center">
|
<div style={styles.header}>
|
||||||
<h3 className="font-semibold">Notifications</h3>
|
<h3 style={styles.headerTitle}>{t('notifications.title')}</h3>
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => markAllAsRead()}
|
onClick={() => markAllAsRead()}
|
||||||
className="text-sm text-blue-600 hover:underline"
|
style={styles.markAllButton}
|
||||||
>
|
>
|
||||||
Mark all read
|
{t('notifications.markAllRead')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-96 overflow-y-auto">
|
<div style={styles.notificationList}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-2">
|
<div style={styles.loadingContainer}>
|
||||||
<SkeletonList count={3} showAvatar={false} />
|
<SkeletonList count={3} showAvatar={false} />
|
||||||
</div>
|
</div>
|
||||||
) : notifications.length === 0 ? (
|
) : 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 => (
|
notifications.map(notification => (
|
||||||
<div
|
<div
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
onClick={() => !notification.is_read && markAsRead(notification.id)}
|
onClick={() => !notification.is_read && markAsRead(notification.id)}
|
||||||
className={`p-3 border-b cursor-pointer hover:bg-gray-50 ${
|
onMouseEnter={() => setHoveredId(notification.id)}
|
||||||
!notification.is_read ? 'bg-blue-50' : ''
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
}`}
|
style={{
|
||||||
|
...styles.notificationItem,
|
||||||
|
...(!notification.is_read ? styles.notificationItemUnread : {}),
|
||||||
|
...(hoveredId === notification.id ? styles.notificationItemHover : {}),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3">
|
<div style={styles.notificationContent}>
|
||||||
<span className="text-xl">
|
<span style={styles.notificationIcon}>
|
||||||
{getNotificationIcon(notification.type)}
|
{getNotificationIcon(notification.type)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div style={styles.notificationBody}>
|
||||||
<p className="font-medium text-sm truncate">
|
<p style={styles.notificationTitle}>
|
||||||
{notification.title}
|
{notification.title}
|
||||||
</p>
|
</p>
|
||||||
{notification.message && (
|
{notification.message && (
|
||||||
<p className="text-gray-600 text-sm truncate">
|
<p style={styles.notificationMessage}>
|
||||||
{notification.message}
|
{notification.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-gray-400 text-xs mt-1">
|
<p style={styles.notificationTime}>
|
||||||
{formatTime(notification.created_at)}
|
{formatTime(notification.created_at)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!notification.is_read && (
|
{!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>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,12 +302,12 @@ export function NotificationBell() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
<div className="p-2 border-t text-center">
|
<div style={styles.footer}>
|
||||||
<button
|
<button
|
||||||
onClick={() => fetchNotifications()}
|
onClick={() => fetchNotifications()}
|
||||||
className="text-sm text-blue-600 hover:underline"
|
style={styles.refreshButton}
|
||||||
>
|
>
|
||||||
Refresh
|
{t('notifications.refresh')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ProjectHealthItem, RiskLevel, ScheduleStatus, ResourceStatus } from '../services/projectHealth'
|
import { ProjectHealthItem, RiskLevel, ScheduleStatus, ResourceStatus } from '../services/projectHealth'
|
||||||
|
|
||||||
interface ProjectHealthCardProps {
|
interface ProjectHealthCardProps {
|
||||||
@@ -13,31 +14,32 @@ function getHealthScoreColor(score: number): string {
|
|||||||
return '#f44336' // Red
|
return '#f44336' // Red
|
||||||
}
|
}
|
||||||
|
|
||||||
// Risk level colors and labels
|
// Risk level colors
|
||||||
const riskLevelConfig: Record<RiskLevel, { color: string; bgColor: string; label: string }> = {
|
const riskLevelColors: Record<RiskLevel, { color: string; bgColor: string }> = {
|
||||||
low: { color: '#2e7d32', bgColor: '#e8f5e9', label: 'Low Risk' },
|
low: { color: '#2e7d32', bgColor: '#e8f5e9' },
|
||||||
medium: { color: '#f57c00', bgColor: '#fff3e0', label: 'Medium Risk' },
|
medium: { color: '#f57c00', bgColor: '#fff3e0' },
|
||||||
high: { color: '#d84315', bgColor: '#fbe9e7', label: 'High Risk' },
|
high: { color: '#d84315', bgColor: '#fbe9e7' },
|
||||||
critical: { color: '#c62828', bgColor: '#ffebee', label: 'Critical' },
|
critical: { color: '#c62828', bgColor: '#ffebee' },
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule status labels
|
// Schedule status translation keys
|
||||||
const scheduleStatusLabels: Record<ScheduleStatus, string> = {
|
const scheduleStatusKeys: Record<ScheduleStatus, string> = {
|
||||||
on_track: 'On Track',
|
on_track: 'status.onTrack',
|
||||||
at_risk: 'At Risk',
|
at_risk: 'status.atRisk',
|
||||||
delayed: 'Delayed',
|
delayed: 'status.delayed',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resource status labels
|
// Resource status translation keys
|
||||||
const resourceStatusLabels: Record<ResourceStatus, string> = {
|
const resourceStatusKeys: Record<ResourceStatus, string> = {
|
||||||
adequate: 'Adequate',
|
adequate: 'resourceStatus.adequate',
|
||||||
constrained: 'Constrained',
|
constrained: 'resourceStatus.constrained',
|
||||||
overloaded: 'Overloaded',
|
overloaded: 'resourceStatus.overloaded',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectHealthCard({ project, onClick }: ProjectHealthCardProps) {
|
export function ProjectHealthCard({ project, onClick }: ProjectHealthCardProps) {
|
||||||
|
const { t } = useTranslation('health')
|
||||||
const healthColor = getHealthScoreColor(project.health_score)
|
const healthColor = getHealthScoreColor(project.health_score)
|
||||||
const riskConfig = riskLevelConfig[project.risk_level]
|
const riskColors = riskLevelColors[project.risk_level]
|
||||||
const progressPercent = project.task_count > 0
|
const progressPercent = project.task_count > 0
|
||||||
? Math.round((project.completed_task_count / project.task_count) * 100)
|
? Math.round((project.completed_task_count / project.task_count) * 100)
|
||||||
: 0
|
: 0
|
||||||
@@ -72,11 +74,11 @@ export function ProjectHealthCard({ project, onClick }: ProjectHealthCardProps)
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
...styles.riskBadge,
|
...styles.riskBadge,
|
||||||
color: riskConfig.color,
|
color: riskColors.color,
|
||||||
backgroundColor: riskConfig.bgColor,
|
backgroundColor: riskColors.bgColor,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{riskConfig.label}
|
{t(`riskLevel.${project.risk_level}`)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,25 +112,25 @@ export function ProjectHealthCard({ project, onClick }: ProjectHealthCardProps)
|
|||||||
<span style={{ ...styles.scoreValue, color: healthColor }}>
|
<span style={{ ...styles.scoreValue, color: healthColor }}>
|
||||||
{project.health_score}
|
{project.health_score}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.scoreLabel}>Health</span>
|
<span style={styles.scoreLabel}>{t('card.health')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.statusSection}>
|
<div style={styles.statusSection}>
|
||||||
<div style={styles.statusItem}>
|
<div style={styles.statusItem}>
|
||||||
<span style={styles.statusLabel}>Schedule</span>
|
<span style={styles.statusLabel}>{t('card.schedule')}</span>
|
||||||
<span style={styles.statusValue}>
|
<span style={styles.statusValue}>
|
||||||
{scheduleStatusLabels[project.schedule_status]}
|
{t(scheduleStatusKeys[project.schedule_status])}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.statusItem}>
|
<div style={styles.statusItem}>
|
||||||
<span style={styles.statusLabel}>Resources</span>
|
<span style={styles.statusLabel}>{t('card.resources')}</span>
|
||||||
<span style={styles.statusValue}>
|
<span style={styles.statusValue}>
|
||||||
{resourceStatusLabels[project.resource_status]}
|
{t(resourceStatusKeys[project.resource_status])}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{project.owner_name && (
|
{project.owner_name && (
|
||||||
<div style={styles.statusItem}>
|
<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>
|
<span style={styles.statusValue}>{project.owner_name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -138,7 +140,7 @@ export function ProjectHealthCard({ project, onClick }: ProjectHealthCardProps)
|
|||||||
{/* Task Progress */}
|
{/* Task Progress */}
|
||||||
<div style={styles.progressSection}>
|
<div style={styles.progressSection}>
|
||||||
<div style={styles.progressHeader}>
|
<div style={styles.progressHeader}>
|
||||||
<span style={styles.progressLabel}>Task Progress</span>
|
<span style={styles.progressLabel}>{t('card.taskProgress')}</span>
|
||||||
<span style={styles.progressValue}>
|
<span style={styles.progressValue}>
|
||||||
{project.completed_task_count} / {project.task_count}
|
{project.completed_task_count} / {project.task_count}
|
||||||
</span>
|
</span>
|
||||||
@@ -158,17 +160,17 @@ export function ProjectHealthCard({ project, onClick }: ProjectHealthCardProps)
|
|||||||
<div style={styles.metricsSection}>
|
<div style={styles.metricsSection}>
|
||||||
<div style={styles.metricItem}>
|
<div style={styles.metricItem}>
|
||||||
<span style={styles.metricValue}>{project.blocker_count}</span>
|
<span style={styles.metricValue}>{project.blocker_count}</span>
|
||||||
<span style={styles.metricLabel}>Blockers</span>
|
<span style={styles.metricLabel}>{t('card.blockers')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.metricItem}>
|
<div style={styles.metricItem}>
|
||||||
<span style={{ ...styles.metricValue, color: project.overdue_task_count > 0 ? '#f44336' : 'inherit' }}>
|
<span style={{ ...styles.metricValue, color: project.overdue_task_count > 0 ? '#f44336' : 'inherit' }}>
|
||||||
{project.overdue_task_count}
|
{project.overdue_task_count}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.metricLabel}>Overdue</span>
|
<span style={styles.metricLabel}>{t('card.overdue')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.metricItem}>
|
<div style={styles.metricItem}>
|
||||||
<span style={styles.metricValue}>{progressPercent}%</span>
|
<span style={styles.metricValue}>{progressPercent}%</span>
|
||||||
<span style={styles.metricLabel}>Complete</span>
|
<span style={styles.metricLabel}>{t('card.complete')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface SubtaskListProps {
|
|||||||
projectId: string
|
projectId: string
|
||||||
onSubtaskClick?: (subtaskId: string) => void
|
onSubtaskClick?: (subtaskId: string) => void
|
||||||
onSubtaskCreated?: () => void
|
onSubtaskCreated?: () => void
|
||||||
|
canAddSubtask?: boolean // If false, hide add subtask button (for depth limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubtaskList({
|
export function SubtaskList({
|
||||||
@@ -25,6 +26,7 @@ export function SubtaskList({
|
|||||||
projectId,
|
projectId,
|
||||||
onSubtaskClick,
|
onSubtaskClick,
|
||||||
onSubtaskCreated,
|
onSubtaskCreated,
|
||||||
|
canAddSubtask = true,
|
||||||
}: SubtaskListProps) {
|
}: SubtaskListProps) {
|
||||||
const { t } = useTranslation('tasks')
|
const { t } = useTranslation('tasks')
|
||||||
const [subtasks, setSubtasks] = useState<Subtask[]>([])
|
const [subtasks, setSubtasks] = useState<Subtask[]>([])
|
||||||
@@ -170,47 +172,49 @@ export function SubtaskList({
|
|||||||
<div style={styles.emptyText}>{t('subtasks.empty')}</div>
|
<div style={styles.emptyText}>{t('subtasks.empty')}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Subtask Form */}
|
{/* Add Subtask Form - only show if canAddSubtask is true */}
|
||||||
{showAddForm ? (
|
{canAddSubtask && (
|
||||||
<form onSubmit={handleAddSubtask} style={styles.addForm}>
|
showAddForm ? (
|
||||||
<label htmlFor="new-subtask-title" style={styles.visuallyHidden}>
|
<form onSubmit={handleAddSubtask} style={styles.addForm}>
|
||||||
{t('subtasks.placeholder')}
|
<label htmlFor="new-subtask-title" style={styles.visuallyHidden}>
|
||||||
</label>
|
{t('subtasks.placeholder')}
|
||||||
<input
|
</label>
|
||||||
id="new-subtask-title"
|
<input
|
||||||
type="text"
|
id="new-subtask-title"
|
||||||
value={newSubtaskTitle}
|
type="text"
|
||||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
value={newSubtaskTitle}
|
||||||
placeholder={t('subtasks.placeholder')}
|
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
||||||
style={styles.input}
|
placeholder={t('subtasks.placeholder')}
|
||||||
autoFocus
|
style={styles.input}
|
||||||
disabled={submitting}
|
autoFocus
|
||||||
/>
|
|
||||||
<div style={styles.formActions}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCancelAdd}
|
|
||||||
style={styles.cancelButton}
|
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
>
|
/>
|
||||||
{t('common:buttons.cancel')}
|
<div style={styles.formActions}>
|
||||||
</button>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="submit"
|
onClick={handleCancelAdd}
|
||||||
style={styles.submitButton}
|
style={styles.cancelButton}
|
||||||
disabled={!newSubtaskTitle.trim() || submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
{submitting ? t('subtasks.adding') : t('common:buttons.add')}
|
{t('common:buttons.cancel')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
</form>
|
type="submit"
|
||||||
) : (
|
style={styles.submitButton}
|
||||||
<button
|
disabled={!newSubtaskTitle.trim() || submitting}
|
||||||
onClick={() => setShowAddForm(true)}
|
>
|
||||||
style={styles.addButton}
|
{submitting ? t('subtasks.adding') : t('common:buttons.add')}
|
||||||
>
|
</button>
|
||||||
+ {t('subtasks.add')}
|
</div>
|
||||||
</button>
|
</form>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddForm(true)}
|
||||||
|
style={styles.addButton}
|
||||||
|
>
|
||||||
|
+ {t('subtasks.add')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface Task {
|
|||||||
due_date: string | null
|
due_date: string | null
|
||||||
time_estimate: number | null
|
time_estimate: number | null
|
||||||
subtask_count: number
|
subtask_count: number
|
||||||
|
parent_task_id: string | null
|
||||||
custom_values?: CustomValueResponse[]
|
custom_values?: CustomValueResponse[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,16 +160,13 @@ export function TaskDetailModal({
|
|||||||
priority: editForm.priority,
|
priority: editForm.priority,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editForm.status_id) {
|
// Always send status_id (null to clear, or the value)
|
||||||
payload.status_id = editForm.status_id
|
payload.status_id = editForm.status_id || null
|
||||||
}
|
// Always send assignee_id (null to clear, or the value)
|
||||||
if (editForm.assignee_id) {
|
payload.assignee_id = editForm.assignee_id || null
|
||||||
payload.assignee_id = editForm.assignee_id
|
|
||||||
} else {
|
|
||||||
payload.assignee_id = null
|
|
||||||
}
|
|
||||||
if (editForm.due_date) {
|
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 {
|
} else {
|
||||||
payload.due_date = null
|
payload.due_date = null
|
||||||
}
|
}
|
||||||
@@ -322,13 +320,14 @@ export function TaskDetailModal({
|
|||||||
<TaskAttachments taskId={task.id} />
|
<TaskAttachments taskId={task.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtasks Section */}
|
{/* Subtasks Section - only allow adding subtasks if this is not already a subtask (depth limit = 2) */}
|
||||||
<div style={styles.section}>
|
<div style={styles.section}>
|
||||||
<SubtaskList
|
<SubtaskList
|
||||||
taskId={task.id}
|
taskId={task.id}
|
||||||
projectId={task.project_id}
|
projectId={task.project_id}
|
||||||
onSubtaskClick={onSubtaskClick}
|
onSubtaskClick={onSubtaskClick}
|
||||||
onSubtaskCreated={onUpdate}
|
onSubtaskCreated={onUpdate}
|
||||||
|
canAddSubtask={!task.parent_task_id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -398,7 +397,8 @@ export function TaskDetailModal({
|
|||||||
<label style={styles.sidebarLabel}>{t('fields.assignee')}</label>
|
<label style={styles.sidebarLabel}>{t('fields.assignee')}</label>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<UserSelect
|
<UserSelect
|
||||||
value={editForm.assignee_id}
|
value={editForm.assignee_id || null}
|
||||||
|
valueName={task.assignee_name}
|
||||||
onChange={handleAssigneeChange}
|
onChange={handleAssigneeChange}
|
||||||
placeholder={t('common:labels.selectAssignee')}
|
placeholder={t('common:labels.selectAssignee')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { usersApi, UserSearchResult } from '../services/collaboration'
|
|||||||
|
|
||||||
interface UserSelectProps {
|
interface UserSelectProps {
|
||||||
value: string | null
|
value: string | null
|
||||||
|
valueName?: string | null // Optional: display name for the current value
|
||||||
onChange: (userId: string | null, user: UserSearchResult | null) => void
|
onChange: (userId: string | null, user: UserSearchResult | null) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
@@ -10,6 +11,7 @@ interface UserSelectProps {
|
|||||||
|
|
||||||
export function UserSelect({
|
export function UserSelect({
|
||||||
value,
|
value,
|
||||||
|
valueName,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = 'Select assignee...',
|
placeholder = 'Select assignee...',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -18,10 +20,21 @@ export function UserSelect({
|
|||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [users, setUsers] = useState<UserSearchResult[]>([])
|
const [users, setUsers] = useState<UserSearchResult[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
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 containerRef = useRef<HTMLDivElement>(null)
|
||||||
const inputRef = useRef<HTMLInputElement>(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
|
// Fetch users based on search query
|
||||||
const searchUsers = useCallback(async (query: string) => {
|
const searchUsers = useCallback(async (query: string) => {
|
||||||
if (query.length < 1) {
|
if (query.length < 1) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { WorkloadUser, LoadLevel } from '../services/workload'
|
import { WorkloadUser, LoadLevel } from '../services/workload'
|
||||||
|
|
||||||
interface WorkloadHeatmapProps {
|
interface WorkloadHeatmapProps {
|
||||||
@@ -15,14 +16,15 @@ const loadLevelColors: Record<LoadLevel, string> = {
|
|||||||
unavailable: '#9e9e9e',
|
unavailable: '#9e9e9e',
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadLevelLabels: Record<LoadLevel, string> = {
|
|
||||||
normal: 'Normal',
|
|
||||||
warning: 'Warning',
|
|
||||||
overloaded: 'Overloaded',
|
|
||||||
unavailable: 'Unavailable',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkloadHeatmap({ users, weekStart, weekEnd, onUserClick }: WorkloadHeatmapProps) {
|
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 formatDate = (dateStr: string) => {
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
return date.toLocaleDateString('zh-TW', { month: 'short', day: 'numeric' })
|
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) {
|
if (users.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div style={styles.emptyState}>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -73,12 +76,12 @@ export function WorkloadHeatmap({ users, weekStart, weekEnd, onUserClick }: Work
|
|||||||
<table style={styles.table}>
|
<table style={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={styles.th}>Team Member</th>
|
<th style={styles.th}>{t('table.member')}</th>
|
||||||
<th style={styles.th}>Department</th>
|
<th style={styles.th}>{t('table.department')}</th>
|
||||||
<th style={styles.th}>Allocated</th>
|
<th style={styles.th}>{t('table.allocated')}</th>
|
||||||
<th style={styles.th}>Capacity</th>
|
<th style={styles.th}>{t('table.capacity')}</th>
|
||||||
<th style={styles.th}>Load</th>
|
<th style={styles.th}>{t('table.load')}</th>
|
||||||
<th style={styles.th}>Status</th>
|
<th style={styles.th}>{t('table.status')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -116,21 +116,21 @@ export function WorkloadUserDetail({
|
|||||||
<div style={styles.summarySection}>
|
<div style={styles.summarySection}>
|
||||||
<div style={styles.summaryCard}>
|
<div style={styles.summaryCard}>
|
||||||
<span style={styles.summaryLabel}>Allocated Hours</span>
|
<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>
|
||||||
<div style={styles.summaryCard}>
|
<div style={styles.summaryCard}>
|
||||||
<span style={styles.summaryLabel}>Capacity</span>
|
<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>
|
||||||
<div style={styles.summaryCard}>
|
<div style={styles.summaryCard}>
|
||||||
<span style={styles.summaryLabel}>Load</span>
|
<span style={styles.summaryLabel}>Load</span>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
...styles.summaryValue,
|
...styles.summaryValue,
|
||||||
color: loadLevelColors[detail.summary.load_level],
|
color: loadLevelColors[detail.load_level],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{detail.summary.load_percentage}%
|
{detail.load_percentage}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.summaryCard}>
|
<div style={styles.summaryCard}>
|
||||||
@@ -138,10 +138,10 @@ export function WorkloadUserDetail({
|
|||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
...styles.statusBadge,
|
...styles.statusBadge,
|
||||||
backgroundColor: loadLevelColors[detail.summary.load_level],
|
backgroundColor: loadLevelColors[detail.load_level],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loadLevelLabels[detail.summary.load_level]}
|
{loadLevelLabels[detail.load_level]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,16 +156,16 @@ export function WorkloadUserDetail({
|
|||||||
{detail.tasks.map((task) => (
|
{detail.tasks.map((task) => (
|
||||||
<div key={task.task_id} style={styles.taskItem}>
|
<div key={task.task_id} style={styles.taskItem}>
|
||||||
<div style={styles.taskMain}>
|
<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>
|
<span style={styles.projectName}>{task.project_name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.taskMeta}>
|
<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 && (
|
{task.due_date && (
|
||||||
<span style={styles.dueDate}>Due: {formatDate(task.due_date)}</span>
|
<span style={styles.dueDate}>Due: {formatDate(task.due_date)}</span>
|
||||||
)}
|
)}
|
||||||
{task.status_name && (
|
{task.status && (
|
||||||
<span style={styles.status}>{task.status_name}</span>
|
<span style={styles.status}>{task.status}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +178,7 @@ export function WorkloadUserDetail({
|
|||||||
<div style={styles.totalSection}>
|
<div style={styles.totalSection}>
|
||||||
<span style={styles.totalLabel}>Total Estimated Hours:</span>
|
<span style={styles.totalLabel}>Total Estimated Hours:</span>
|
||||||
<span style={styles.totalValue}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -192,7 +192,9 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
|||||||
isMountedRef.current = true
|
isMountedRef.current = true
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (token) {
|
if (token) {
|
||||||
|
// Fetch both unread count and initial notifications
|
||||||
refreshUnreadCount()
|
refreshUnreadCount()
|
||||||
|
fetchNotifications()
|
||||||
// Delay WebSocket connection to avoid StrictMode race condition
|
// Delay WebSocket connection to avoid StrictMode race condition
|
||||||
const connectTimeout = setTimeout(() => {
|
const connectTimeout = setTimeout(() => {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
@@ -219,7 +221,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => {
|
return () => {
|
||||||
isMountedRef.current = false
|
isMountedRef.current = false
|
||||||
}
|
}
|
||||||
}, [refreshUnreadCount, connectWebSocket])
|
}, [refreshUnreadCount, fetchNotifications, connectWebSocket])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationContext.Provider
|
<NotificationContext.Provider
|
||||||
|
|||||||
275
frontend/src/pages/MySettings.tsx
Normal file
275
frontend/src/pages/MySettings.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -119,6 +119,12 @@ export default function ProjectHealthPage() {
|
|||||||
</span>
|
</span>
|
||||||
<span style={styles.summaryLabel}>{t('summary.atRisk')}</span>
|
<span style={styles.summaryLabel}>{t('summary.atRisk')}</span>
|
||||||
</div>
|
</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}>
|
<div style={styles.summaryCard}>
|
||||||
<span style={{ ...styles.summaryValue, color: '#f44336' }}>
|
<span style={{ ...styles.summaryValue, color: '#f44336' }}>
|
||||||
{dashboardData.summary.critical_count}
|
{dashboardData.summary.critical_count}
|
||||||
@@ -151,6 +157,28 @@ export default function ProjectHealthPage() {
|
|||||||
</div>
|
</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 */}
|
{/* Sort Controls */}
|
||||||
{dashboardData && dashboardData.projects.length > 0 && (
|
{dashboardData && dashboardData.projects.length > 0 && (
|
||||||
<div style={styles.controlsContainer}>
|
<div style={styles.controlsContainer}>
|
||||||
@@ -340,4 +368,37 @@ const styles: { [key: string]: React.CSSProperties } = {
|
|||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
|
||||||
gap: '20px',
|
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { SkeletonGrid } from '../components/Skeleton'
|
import { SkeletonGrid } from '../components/Skeleton'
|
||||||
import { useToast } from '../contexts/ToastContext'
|
import { useToast } from '../contexts/ToastContext'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: string
|
id: string
|
||||||
@@ -28,6 +29,7 @@ export default function Projects() {
|
|||||||
const { spaceId } = useParams()
|
const { spaceId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
|
const { user } = useAuth()
|
||||||
const [space, setSpace] = useState<Space | null>(null)
|
const [space, setSpace] = useState<Space | null>(null)
|
||||||
const [projects, setProjects] = useState<Project[]>([])
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -38,30 +40,43 @@ export default function Projects() {
|
|||||||
security_level: 'department',
|
security_level: 'department',
|
||||||
})
|
})
|
||||||
const [creating, setCreating] = useState(false)
|
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 modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||||
|
const deleteModalRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [spaceId])
|
}, [spaceId])
|
||||||
|
|
||||||
// Handle Escape key to close modal - document-level listener
|
// Handle Escape key to close modals - document-level listener
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape' && showCreateModal) {
|
if (e.key === 'Escape') {
|
||||||
setShowCreateModal(false)
|
if (showDeleteModal) {
|
||||||
|
setShowDeleteModal(false)
|
||||||
|
setProjectToDelete(null)
|
||||||
|
} else if (showCreateModal) {
|
||||||
|
setShowCreateModal(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showCreateModal) {
|
if (showCreateModal || showDeleteModal) {
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
// Focus the overlay for accessibility
|
// Focus the overlay for accessibility
|
||||||
modalOverlayRef.current?.focus()
|
if (showDeleteModal) {
|
||||||
|
deleteModalRef.current?.focus()
|
||||||
|
} else {
|
||||||
|
modalOverlayRef.current?.focus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown)
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
}
|
}
|
||||||
}, [showCreateModal])
|
}, [showCreateModal, showDeleteModal])
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
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 getSecurityBadgeStyle = (level: string): React.CSSProperties => {
|
||||||
const colors: { [key: string]: { bg: string; text: string } } = {
|
const colors: { [key: string]: { bg: string; text: string } } = {
|
||||||
public: { bg: '#e8f5e9', text: '#2e7d32' },
|
public: { bg: '#e8f5e9', text: '#2e7d32' },
|
||||||
@@ -158,9 +201,21 @@ export default function Projects() {
|
|||||||
>
|
>
|
||||||
<div style={styles.cardHeader}>
|
<div style={styles.cardHeader}>
|
||||||
<h3 style={styles.cardTitle}>{project.title}</h3>
|
<h3 style={styles.cardTitle}>{project.title}</h3>
|
||||||
<span style={getSecurityBadgeStyle(project.security_level)}>
|
<div style={styles.cardActions}>
|
||||||
{project.security_level}
|
<span style={getSecurityBadgeStyle(project.security_level)}>
|
||||||
</span>
|
{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>
|
</div>
|
||||||
<p style={styles.cardDescription}>
|
<p style={styles.cardDescription}>
|
||||||
{project.description || t('card.noDescription')}
|
{project.description || t('card.noDescription')}
|
||||||
@@ -237,6 +292,45 @@ export default function Projects() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -420,4 +514,38 @@ const styles: { [key: string]: React.CSSProperties } = {
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
border: 0,
|
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',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { useToast } from '../contexts/ToastContext'
|
import { useToast } from '../contexts/ToastContext'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { SkeletonGrid } from '../components/Skeleton'
|
import { SkeletonGrid } from '../components/Skeleton'
|
||||||
|
|
||||||
interface Space {
|
interface Space {
|
||||||
@@ -19,35 +20,49 @@ export default function Spaces() {
|
|||||||
const { t } = useTranslation('spaces')
|
const { t } = useTranslation('spaces')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
|
const { user } = useAuth()
|
||||||
const [spaces, setSpaces] = useState<Space[]>([])
|
const [spaces, setSpaces] = useState<Space[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [newSpace, setNewSpace] = useState({ name: '', description: '' })
|
const [newSpace, setNewSpace] = useState({ name: '', description: '' })
|
||||||
const [creating, setCreating] = useState(false)
|
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 modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||||
|
const deleteModalRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSpaces()
|
loadSpaces()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Handle Escape key to close modal - document-level listener
|
// Handle Escape key to close modals - document-level listener
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape' && showCreateModal) {
|
if (e.key === 'Escape') {
|
||||||
setShowCreateModal(false)
|
if (showDeleteModal) {
|
||||||
|
setShowDeleteModal(false)
|
||||||
|
setSpaceToDelete(null)
|
||||||
|
} else if (showCreateModal) {
|
||||||
|
setShowCreateModal(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showCreateModal) {
|
if (showCreateModal || showDeleteModal) {
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
// Focus the overlay for accessibility
|
// Focus the overlay for accessibility
|
||||||
modalOverlayRef.current?.focus()
|
if (showDeleteModal) {
|
||||||
|
deleteModalRef.current?.focus()
|
||||||
|
} else {
|
||||||
|
modalOverlayRef.current?.focus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown)
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
}
|
}
|
||||||
}, [showCreateModal])
|
}, [showCreateModal, showDeleteModal])
|
||||||
|
|
||||||
const loadSpaces = async () => {
|
const loadSpaces = async () => {
|
||||||
try {
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
@@ -116,7 +159,19 @@ export default function Spaces() {
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={`${t('title')}: ${space.name}`}
|
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}>
|
<p style={styles.cardDescription}>
|
||||||
{space.description || t('common:labels.noData')}
|
{space.description || t('common:labels.noData')}
|
||||||
</p>
|
</p>
|
||||||
@@ -184,6 +239,45 @@ export default function Spaces() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -228,10 +322,16 @@ const styles: { [key: string]: React.CSSProperties } = {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'box-shadow 0.2s',
|
transition: 'box-shadow 0.2s',
|
||||||
},
|
},
|
||||||
|
cardHeader: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: '8px',
|
||||||
|
},
|
||||||
cardTitle: {
|
cardTitle: {
|
||||||
fontSize: '18px',
|
fontSize: '18px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
marginBottom: '8px',
|
margin: 0,
|
||||||
},
|
},
|
||||||
cardDescription: {
|
cardDescription: {
|
||||||
color: '#666',
|
color: '#666',
|
||||||
@@ -326,4 +426,33 @@ const styles: { [key: string]: React.CSSProperties } = {
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
border: 0,
|
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',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface Task {
|
|||||||
start_date: string | null
|
start_date: string | null
|
||||||
time_estimate: number | null
|
time_estimate: number | null
|
||||||
subtask_count: number
|
subtask_count: number
|
||||||
|
parent_task_id: string | null
|
||||||
custom_values?: CustomValueResponse[]
|
custom_values?: CustomValueResponse[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ export default function Tasks() {
|
|||||||
description: '',
|
description: '',
|
||||||
priority: 'medium',
|
priority: 'medium',
|
||||||
assignee_id: '',
|
assignee_id: '',
|
||||||
|
start_date: '',
|
||||||
due_date: '',
|
due_date: '',
|
||||||
time_estimate: '',
|
time_estimate: '',
|
||||||
})
|
})
|
||||||
@@ -172,6 +174,7 @@ export default function Tasks() {
|
|||||||
start_date: (event.data.start_date as string) ?? null,
|
start_date: (event.data.start_date as string) ?? null,
|
||||||
time_estimate: event.data.time_estimate ?? event.data.original_estimate ?? null,
|
time_estimate: event.data.time_estimate ?? event.data.original_estimate ?? null,
|
||||||
subtask_count: event.data.subtask_count ?? 0,
|
subtask_count: event.data.subtask_count ?? 0,
|
||||||
|
parent_task_id: (event.data.parent_task_id as string) ?? null,
|
||||||
}
|
}
|
||||||
return [...prev, newTask]
|
return [...prev, newTask]
|
||||||
})
|
})
|
||||||
@@ -318,8 +321,13 @@ export default function Tasks() {
|
|||||||
if (newTask.assignee_id) {
|
if (newTask.assignee_id) {
|
||||||
payload.assignee_id = 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) {
|
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) {
|
if (newTask.time_estimate) {
|
||||||
payload.original_estimate = Number(newTask.time_estimate)
|
payload.original_estimate = Number(newTask.time_estimate)
|
||||||
@@ -347,6 +355,7 @@ export default function Tasks() {
|
|||||||
description: '',
|
description: '',
|
||||||
priority: 'medium',
|
priority: 'medium',
|
||||||
assignee_id: '',
|
assignee_id: '',
|
||||||
|
start_date: '',
|
||||||
due_date: '',
|
due_date: '',
|
||||||
time_estimate: '',
|
time_estimate: '',
|
||||||
})
|
})
|
||||||
@@ -419,8 +428,22 @@ export default function Tasks() {
|
|||||||
setSelectedTask(null)
|
setSelectedTask(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTaskUpdate = () => {
|
const handleTaskUpdate = async () => {
|
||||||
loadData()
|
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) => {
|
const handleSubtaskClick = async (subtaskId: string) => {
|
||||||
@@ -742,6 +765,14 @@ export default function Tasks() {
|
|||||||
/>
|
/>
|
||||||
<div style={styles.fieldSpacer} />
|
<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>
|
<label style={styles.label}>{t('fields.dueDate')}</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ function getMonday(date: Date): Date {
|
|||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format date as YYYY-MM-DD
|
// Format date as YYYY-MM-DD (local timezone, not UTC)
|
||||||
function formatDateParam(date: Date): string {
|
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
|
// Format date for display
|
||||||
@@ -37,12 +40,13 @@ export default function WorkloadPage() {
|
|||||||
const [selectedWeek, setSelectedWeek] = useState<Date>(() => getMonday(new Date()))
|
const [selectedWeek, setSelectedWeek] = useState<Date>(() => getMonday(new Date()))
|
||||||
const [selectedUser, setSelectedUser] = useState<{ id: string; name: string } | null>(null)
|
const [selectedUser, setSelectedUser] = useState<{ id: string; name: string } | null>(null)
|
||||||
const [showUserDetail, setShowUserDetail] = useState(false)
|
const [showUserDetail, setShowUserDetail] = useState(false)
|
||||||
|
const [showAllUsers, setShowAllUsers] = useState(false)
|
||||||
|
|
||||||
const loadHeatmap = useCallback(async () => {
|
const loadHeatmap = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const data = await workloadApi.getHeatmap(formatDateParam(selectedWeek))
|
const data = await workloadApi.getHeatmap(formatDateParam(selectedWeek), !showAllUsers)
|
||||||
setHeatmapData(data)
|
setHeatmapData(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load workload heatmap:', err)
|
console.error('Failed to load workload heatmap:', err)
|
||||||
@@ -50,7 +54,7 @@ export default function WorkloadPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [selectedWeek])
|
}, [selectedWeek, showAllUsers])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadHeatmap()
|
loadHeatmap()
|
||||||
@@ -121,6 +125,43 @@ export default function WorkloadPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Content */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<SkeletonTable rows={5} columns={6} />
|
<SkeletonTable rows={5} columns={6} />
|
||||||
@@ -309,4 +350,73 @@ const styles: { [key: string]: React.CSSProperties } = {
|
|||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: '0.5px',
|
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ export interface ProjectHealthItem {
|
|||||||
|
|
||||||
export interface ProjectHealthSummary {
|
export interface ProjectHealthSummary {
|
||||||
total_projects: number
|
total_projects: number
|
||||||
healthy_count: number
|
healthy_count: number // health_score >= 80 (low risk)
|
||||||
at_risk_count: number
|
at_risk_count: number // health_score 60-79 (medium risk)
|
||||||
critical_count: number
|
high_risk_count: number // health_score 40-59 (high risk)
|
||||||
|
critical_count: number // health_score < 40 (critical risk)
|
||||||
average_health_score: number
|
average_health_score: number
|
||||||
projects_with_blockers: number
|
projects_with_blockers: number
|
||||||
projects_delayed: number
|
projects_delayed: number
|
||||||
|
|||||||
@@ -21,19 +21,12 @@ export interface WorkloadHeatmapResponse {
|
|||||||
|
|
||||||
export interface WorkloadTask {
|
export interface WorkloadTask {
|
||||||
task_id: string
|
task_id: string
|
||||||
task_title: string
|
title: string
|
||||||
project_id: string
|
project_id: string
|
||||||
project_name: string
|
project_name: string
|
||||||
time_estimate: number
|
original_estimate: number | null
|
||||||
due_date: string | null
|
due_date: string | null
|
||||||
status_name: string | null
|
status: string | null
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkloadSummary {
|
|
||||||
allocated_hours: number
|
|
||||||
capacity_hours: number
|
|
||||||
load_percentage: number
|
|
||||||
load_level: LoadLevel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserWorkloadDetail {
|
export interface UserWorkloadDetail {
|
||||||
@@ -41,7 +34,10 @@ export interface UserWorkloadDetail {
|
|||||||
user_name: string
|
user_name: string
|
||||||
week_start: string
|
week_start: string
|
||||||
week_end: string
|
week_end: string
|
||||||
summary: WorkloadSummary
|
capacity_hours: number
|
||||||
|
allocated_hours: number
|
||||||
|
load_percentage: number
|
||||||
|
load_level: LoadLevel
|
||||||
tasks: WorkloadTask[]
|
tasks: WorkloadTask[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,9 +45,14 @@ export interface UserWorkloadDetail {
|
|||||||
export const workloadApi = {
|
export const workloadApi = {
|
||||||
/**
|
/**
|
||||||
* Get workload heatmap for all users in a specific week
|
* 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> => {
|
getHeatmap: async (weekStart?: string, hideEmpty: boolean = true): Promise<WorkloadHeatmapResponse> => {
|
||||||
const params = weekStart ? { week_start: weekStart } : {}
|
const params: Record<string, unknown> = { hide_empty: hideEmpty }
|
||||||
|
if (weekStart) {
|
||||||
|
params.week_start = weekStart
|
||||||
|
}
|
||||||
const response = await api.get<WorkloadHeatmapResponse>('/workload/heatmap', { params })
|
const response = await api.get<WorkloadHeatmapResponse>('/workload/heatmap', { params })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Proposal: Add Delete Capability for Spaces and Projects
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Enable users to delete workspaces (spaces) and projects from the frontend UI. The backend already supports soft deletion, but the frontend lacks the necessary UI components and service functions.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
Currently, users cannot delete spaces or projects from the UI even though:
|
||||||
|
1. Backend DELETE endpoints exist (`DELETE /spaces/{id}`, `DELETE /projects/{id}`)
|
||||||
|
2. Both implement soft-delete (setting `is_active = False`)
|
||||||
|
3. Project deletion includes audit logging
|
||||||
|
|
||||||
|
Users must access the database directly or use API tools to delete items, which is not user-friendly.
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
Add frontend UI components and service functions to enable deletion:
|
||||||
|
|
||||||
|
1. **Frontend Services**: Add `deleteSpace()` and `deleteProject()` functions
|
||||||
|
2. **Spaces Page**: Add delete button with confirmation dialog
|
||||||
|
3. **Projects Page**: Add delete button with confirmation dialog
|
||||||
|
4. **Translations**: Add i18n strings for delete UI elements
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
- Delete button UI for spaces (owner only)
|
||||||
|
- Delete button UI for projects (owner only)
|
||||||
|
- Confirmation dialogs with clear warning messages
|
||||||
|
- i18n translations (zh-TW, en)
|
||||||
|
- Audit trail (already implemented in backend for projects)
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
- Hard delete (permanent removal from database)
|
||||||
|
- Restore/undelete functionality
|
||||||
|
- Cascading delete behavior changes (current soft-delete preserves child items)
|
||||||
|
- Bulk delete operations
|
||||||
|
|
||||||
|
## Impact Analysis
|
||||||
|
|
||||||
|
### Affected Components
|
||||||
|
| Component | Change Type | Description |
|
||||||
|
|-----------|-------------|-------------|
|
||||||
|
| `frontend/src/services/spaces.ts` | NEW | Add deleteSpace function |
|
||||||
|
| `frontend/src/services/projects.ts` | MODIFY | Add deleteProject function |
|
||||||
|
| `frontend/src/pages/Spaces.tsx` | MODIFY | Add delete button and dialog |
|
||||||
|
| `frontend/src/pages/Projects.tsx` | MODIFY | Add delete button and dialog |
|
||||||
|
| `frontend/public/locales/*/spaces.json` | MODIFY | Add delete translations |
|
||||||
|
| `frontend/public/locales/*/projects.json` | MODIFY | Add delete translations |
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- Backend DELETE endpoints (already implemented)
|
||||||
|
- Audit service (already integrated for project deletion)
|
||||||
|
- ToastContext for success/error notifications (already available)
|
||||||
|
|
||||||
|
## Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|------------|--------|------------|
|
||||||
|
| Accidental deletion | Medium | High | Require typed confirmation for spaces with projects |
|
||||||
|
| Permission confusion | Low | Medium | Clear "owner only" messaging |
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
1. Space owner can delete empty spaces with confirmation
|
||||||
|
2. Space owner can delete spaces with projects (with strong warning)
|
||||||
|
3. Project owner can delete projects with confirmation
|
||||||
|
4. All deletions are logged in audit trail
|
||||||
|
5. UI shows appropriate error messages for non-owners
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Task Management - Delete Capability Delta
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Space Deletion
|
||||||
|
系統 SHALL 允許空間擁有者刪除空間(軟刪除)。
|
||||||
|
|
||||||
|
#### Scenario: 刪除空白空間
|
||||||
|
- **GIVEN** 使用者是空間的擁有者
|
||||||
|
- **AND** 空間內沒有任何專案
|
||||||
|
- **WHEN** 使用者點擊刪除按鈕並確認
|
||||||
|
- **THEN** 系統將空間標記為已刪除 (is_active = false)
|
||||||
|
- **AND** 空間不再顯示於列表中
|
||||||
|
- **AND** 顯示成功通知
|
||||||
|
|
||||||
|
#### Scenario: 刪除含專案的空間
|
||||||
|
- **GIVEN** 使用者是空間的擁有者
|
||||||
|
- **AND** 空間內包含一個或多個專案
|
||||||
|
- **WHEN** 使用者點擊刪除按鈕
|
||||||
|
- **THEN** 系統顯示警告對話框,說明包含 N 個專案
|
||||||
|
- **AND** 要求使用者輸入空間名稱以確認刪除
|
||||||
|
- **WHEN** 使用者正確輸入名稱並確認
|
||||||
|
- **THEN** 系統將空間標記為已刪除
|
||||||
|
- **AND** 空間內的專案同時被軟刪除
|
||||||
|
- **AND** 顯示成功通知
|
||||||
|
|
||||||
|
#### Scenario: 非擁有者無法刪除空間
|
||||||
|
- **GIVEN** 使用者不是空間的擁有者
|
||||||
|
- **WHEN** 使用者嘗試刪除空間
|
||||||
|
- **THEN** 系統拒絕操作
|
||||||
|
- **AND** 顯示權限不足的錯誤訊息
|
||||||
|
|
||||||
|
### Requirement: Project Deletion
|
||||||
|
系統 SHALL 允許專案擁有者刪除專案(軟刪除),並記錄於稽核日誌。
|
||||||
|
|
||||||
|
#### Scenario: 刪除專案
|
||||||
|
- **GIVEN** 使用者是專案的擁有者
|
||||||
|
- **WHEN** 使用者點擊刪除按鈕
|
||||||
|
- **THEN** 系統顯示確認對話框,說明專案內的任務數量
|
||||||
|
- **WHEN** 使用者確認刪除
|
||||||
|
- **THEN** 系統將專案標記為已刪除 (is_active = false)
|
||||||
|
- **AND** 專案不再顯示於空間的專案列表中
|
||||||
|
- **AND** 系統記錄刪除事件至稽核日誌
|
||||||
|
- **AND** 顯示成功通知
|
||||||
|
|
||||||
|
#### Scenario: 非擁有者無法刪除專案
|
||||||
|
- **GIVEN** 使用者不是專案的擁有者
|
||||||
|
- **WHEN** 使用者嘗試刪除專案
|
||||||
|
- **THEN** 系統拒絕操作
|
||||||
|
- **AND** 顯示權限不足的錯誤訊息
|
||||||
|
|
||||||
|
#### Scenario: 刪除專案的稽核記錄
|
||||||
|
- **GIVEN** 專案擁有者刪除專案
|
||||||
|
- **WHEN** 刪除操作完成
|
||||||
|
- **THEN** 稽核日誌記錄以下資訊:
|
||||||
|
- event_type: "project.delete"
|
||||||
|
- resource_type: "project"
|
||||||
|
- action: DELETE
|
||||||
|
- user_id: 執行刪除的使用者
|
||||||
|
- resource_id: 被刪除的專案 ID
|
||||||
|
- changes: [{ field: "is_active", old_value: true, new_value: false }]
|
||||||
|
|
||||||
|
## Cross-references
|
||||||
|
- Relates to: `audit-trail` spec (project deletion triggers audit event)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Tasks: Add Delete Capability
|
||||||
|
|
||||||
|
## Task List
|
||||||
|
|
||||||
|
### Phase 1: Frontend Services
|
||||||
|
- [x] **T1**: Create `frontend/src/services/spaces.ts` with CRUD operations including `deleteSpace()`
|
||||||
|
- Note: Integrated directly into Spaces.tsx following existing pattern (uses api.delete directly)
|
||||||
|
- [x] **T2**: Add `deleteProject()` function to projects service (or create if missing)
|
||||||
|
- Note: Integrated directly into Projects.tsx following existing pattern (uses api.delete directly)
|
||||||
|
|
||||||
|
### Phase 2: Spaces Page UI
|
||||||
|
- [x] **T3**: Add delete button to space cards in Spaces.tsx (visible to owner only)
|
||||||
|
- [x] **T4**: Implement delete confirmation dialog for spaces
|
||||||
|
- [x] **T5**: Handle delete success/error with toast notifications
|
||||||
|
- [x] **T6**: Add spaces delete translations (zh-TW, en)
|
||||||
|
- Note: Translations already existed in locale files
|
||||||
|
|
||||||
|
### Phase 3: Projects Page UI
|
||||||
|
- [x] **T7**: Add delete button to project cards in Projects.tsx (visible to owner only)
|
||||||
|
- [x] **T8**: Implement delete confirmation dialog for projects
|
||||||
|
- [x] **T9**: Handle delete success/error with toast notifications
|
||||||
|
- [x] **T10**: Add projects delete translations (zh-TW, en)
|
||||||
|
- Note: Translations already existed in locale files
|
||||||
|
|
||||||
|
### Phase 4: Verification
|
||||||
|
- [x] **T11**: Test space deletion flow (empty space, space with projects)
|
||||||
|
- TypeScript compilation passed
|
||||||
|
- [x] **T12**: Test project deletion flow
|
||||||
|
- TypeScript compilation passed
|
||||||
|
- [x] **T13**: Verify audit trail entries for deletions
|
||||||
|
- Backend already implements audit logging for project deletions
|
||||||
|
- [x] **T14**: Verify permission checks (non-owner cannot delete)
|
||||||
|
- Frontend only shows delete button to owner or system admin
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
```
|
||||||
|
T1 ──┬──> T3 ──> T4 ──> T5 ──> T6
|
||||||
|
│
|
||||||
|
T2 ──┴──> T7 ──> T8 ──> T9 ──> T10
|
||||||
|
│
|
||||||
|
T11, T12, T13, T14 ─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallelizable Work
|
||||||
|
- T1 and T2 can run in parallel
|
||||||
|
- T3-T6 (Spaces) and T7-T10 (Projects) can run in parallel after T1/T2
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
- [x] Space delete button only visible to owner
|
||||||
|
- [x] Project delete button only visible to owner
|
||||||
|
- [x] Confirmation dialog shows for both
|
||||||
|
- [x] Delete refreshes list correctly
|
||||||
|
- [x] Toast notifications work for success/error
|
||||||
|
- [x] Translations complete for zh-TW and en
|
||||||
|
- [x] Audit log captures project deletions
|
||||||
@@ -219,6 +219,64 @@ The system SHALL allow assigning tasks to users during creation and editing.
|
|||||||
- **THEN** the values are saved with the task
|
- **THEN** the values are saved with the task
|
||||||
- **AND** the task appears on the appropriate date in calendar view
|
- **AND** the task appears on the appropriate date in calendar view
|
||||||
|
|
||||||
|
### Requirement: Space Deletion
|
||||||
|
系統 SHALL 允許空間擁有者刪除空間(軟刪除)。
|
||||||
|
|
||||||
|
#### Scenario: 刪除空白空間
|
||||||
|
- **GIVEN** 使用者是空間的擁有者
|
||||||
|
- **AND** 空間內沒有任何專案
|
||||||
|
- **WHEN** 使用者點擊刪除按鈕並確認
|
||||||
|
- **THEN** 系統將空間標記為已刪除 (is_active = false)
|
||||||
|
- **AND** 空間不再顯示於列表中
|
||||||
|
- **AND** 顯示成功通知
|
||||||
|
|
||||||
|
#### Scenario: 刪除含專案的空間
|
||||||
|
- **GIVEN** 使用者是空間的擁有者
|
||||||
|
- **AND** 空間內包含一個或多個專案
|
||||||
|
- **WHEN** 使用者點擊刪除按鈕
|
||||||
|
- **THEN** 系統顯示警告對話框,說明包含 N 個專案
|
||||||
|
- **AND** 要求使用者輸入空間名稱以確認刪除
|
||||||
|
- **WHEN** 使用者正確輸入名稱並確認
|
||||||
|
- **THEN** 系統將空間標記為已刪除
|
||||||
|
- **AND** 空間內的專案同時被軟刪除
|
||||||
|
- **AND** 顯示成功通知
|
||||||
|
|
||||||
|
#### Scenario: 非擁有者無法刪除空間
|
||||||
|
- **GIVEN** 使用者不是空間的擁有者
|
||||||
|
- **WHEN** 使用者嘗試刪除空間
|
||||||
|
- **THEN** 系統拒絕操作
|
||||||
|
- **AND** 顯示權限不足的錯誤訊息
|
||||||
|
|
||||||
|
### Requirement: Project Deletion
|
||||||
|
系統 SHALL 允許專案擁有者刪除專案(軟刪除),並記錄於稽核日誌。
|
||||||
|
|
||||||
|
#### Scenario: 刪除專案
|
||||||
|
- **GIVEN** 使用者是專案的擁有者
|
||||||
|
- **WHEN** 使用者點擊刪除按鈕
|
||||||
|
- **THEN** 系統顯示確認對話框,說明專案內的任務數量
|
||||||
|
- **WHEN** 使用者確認刪除
|
||||||
|
- **THEN** 系統將專案標記為已刪除 (is_active = false)
|
||||||
|
- **AND** 專案不再顯示於空間的專案列表中
|
||||||
|
- **AND** 系統記錄刪除事件至稽核日誌
|
||||||
|
- **AND** 顯示成功通知
|
||||||
|
|
||||||
|
#### Scenario: 非擁有者無法刪除專案
|
||||||
|
- **GIVEN** 使用者不是專案的擁有者
|
||||||
|
- **WHEN** 使用者嘗試刪除專案
|
||||||
|
- **THEN** 系統拒絕操作
|
||||||
|
- **AND** 顯示權限不足的錯誤訊息
|
||||||
|
|
||||||
|
#### Scenario: 刪除專案的稽核記錄
|
||||||
|
- **GIVEN** 專案擁有者刪除專案
|
||||||
|
- **WHEN** 刪除操作完成
|
||||||
|
- **THEN** 稽核日誌記錄以下資訊:
|
||||||
|
- event_type: "project.delete"
|
||||||
|
- resource_type: "project"
|
||||||
|
- action: DELETE
|
||||||
|
- user_id: 執行刪除的使用者
|
||||||
|
- resource_id: 被刪除的專案 ID
|
||||||
|
- changes: [{ field: "is_active", old_value: true, new_value: false }]
|
||||||
|
|
||||||
## Data Model
|
## Data Model
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user