feat: implement dashboard widgets functionality
Backend: - Add dashboard API router with widget endpoints - Create dashboard schemas for widget data - Add dashboard tests Frontend: - Enhance Dashboard page with widget components - Add dashboard service for API calls - Create reusable dashboard components OpenSpec: - Archive add-dashboard-widgets change - Add dashboard capability specs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
4
backend/app/api/dashboard/__init__.py
Normal file
4
backend/app/api/dashboard/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""Dashboard API module."""
|
||||||
|
from app.api.dashboard.router import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
222
backend/app/api/dashboard/router.py
Normal file
222
backend/app/api/dashboard/router.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"""Dashboard API endpoints.
|
||||||
|
|
||||||
|
Provides a single aggregated endpoint for dashboard data,
|
||||||
|
combining task statistics, workload summary, and project health.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import func, and_, or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.middleware.auth import get_current_user
|
||||||
|
from app.models import User, Task
|
||||||
|
from app.models.task_status import TaskStatus
|
||||||
|
from app.schemas.dashboard import (
|
||||||
|
DashboardResponse,
|
||||||
|
TaskStatistics,
|
||||||
|
WorkloadSummary,
|
||||||
|
HealthSummary,
|
||||||
|
)
|
||||||
|
from app.schemas.workload import LoadLevel
|
||||||
|
from app.services.workload_service import (
|
||||||
|
get_week_bounds,
|
||||||
|
get_current_week_start,
|
||||||
|
calculate_load_percentage,
|
||||||
|
determine_load_level,
|
||||||
|
)
|
||||||
|
from app.services.health_service import HealthService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_statistics(db: Session, user_id: str) -> TaskStatistics:
|
||||||
|
"""
|
||||||
|
Calculate task statistics for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
user_id: User ID to calculate statistics for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TaskStatistics with counts and completion rate
|
||||||
|
"""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
week_start, week_end = get_week_bounds(now.date())
|
||||||
|
week_start_dt = datetime.combine(week_start, datetime.min.time())
|
||||||
|
week_end_dt = datetime.combine(week_end, datetime.max.time())
|
||||||
|
|
||||||
|
# Query all tasks assigned to user (not deleted)
|
||||||
|
base_query = db.query(Task).filter(
|
||||||
|
Task.assignee_id == user_id,
|
||||||
|
Task.is_deleted == False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count total tasks (not done) assigned to user
|
||||||
|
# We need to join with status to check is_done
|
||||||
|
assigned_count = (
|
||||||
|
base_query
|
||||||
|
.outerjoin(TaskStatus, Task.status_id == TaskStatus.id)
|
||||||
|
.filter(
|
||||||
|
or_(
|
||||||
|
TaskStatus.is_done == False,
|
||||||
|
Task.status_id == None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count tasks due this week (not completed)
|
||||||
|
due_this_week = (
|
||||||
|
base_query
|
||||||
|
.outerjoin(TaskStatus, Task.status_id == TaskStatus.id)
|
||||||
|
.filter(
|
||||||
|
Task.due_date >= week_start_dt,
|
||||||
|
Task.due_date <= week_end_dt,
|
||||||
|
or_(
|
||||||
|
TaskStatus.is_done == False,
|
||||||
|
Task.status_id == None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count overdue tasks (past due_date, not completed)
|
||||||
|
overdue_count = (
|
||||||
|
base_query
|
||||||
|
.outerjoin(TaskStatus, Task.status_id == TaskStatus.id)
|
||||||
|
.filter(
|
||||||
|
Task.due_date < now,
|
||||||
|
or_(
|
||||||
|
TaskStatus.is_done == False,
|
||||||
|
Task.status_id == None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate completion rate
|
||||||
|
# Total tasks (including completed) assigned to user
|
||||||
|
total_tasks = base_query.count()
|
||||||
|
|
||||||
|
# Completed tasks
|
||||||
|
completed_tasks = (
|
||||||
|
base_query
|
||||||
|
.join(TaskStatus, Task.status_id == TaskStatus.id)
|
||||||
|
.filter(TaskStatus.is_done == True)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
completion_rate = 0.0
|
||||||
|
if total_tasks > 0:
|
||||||
|
completion_rate = round((completed_tasks / total_tasks) * 100, 1)
|
||||||
|
|
||||||
|
return TaskStatistics(
|
||||||
|
assigned_count=assigned_count,
|
||||||
|
due_this_week=due_this_week,
|
||||||
|
overdue_count=overdue_count,
|
||||||
|
completion_rate=completion_rate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_workload_summary(db: Session, user: User) -> WorkloadSummary:
|
||||||
|
"""
|
||||||
|
Get workload summary for a user for the current week.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
user: User object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WorkloadSummary with hours and load level
|
||||||
|
"""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
week_start, week_end = get_week_bounds(now.date())
|
||||||
|
week_start_dt = datetime.combine(week_start, datetime.min.time())
|
||||||
|
week_end_dt = datetime.combine(week_end, datetime.max.time())
|
||||||
|
|
||||||
|
# Get tasks due this week for user (not completed)
|
||||||
|
tasks = (
|
||||||
|
db.query(Task)
|
||||||
|
.outerjoin(TaskStatus, Task.status_id == TaskStatus.id)
|
||||||
|
.filter(
|
||||||
|
Task.assignee_id == user.id,
|
||||||
|
Task.is_deleted == False,
|
||||||
|
Task.due_date >= week_start_dt,
|
||||||
|
Task.due_date <= week_end_dt,
|
||||||
|
or_(
|
||||||
|
TaskStatus.is_done == False,
|
||||||
|
Task.status_id == None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate allocated hours from original_estimate
|
||||||
|
allocated_hours = Decimal("0")
|
||||||
|
for task in tasks:
|
||||||
|
if task.original_estimate:
|
||||||
|
allocated_hours += task.original_estimate
|
||||||
|
|
||||||
|
capacity_hours = Decimal(str(user.capacity)) if user.capacity else Decimal("40")
|
||||||
|
load_percentage = calculate_load_percentage(allocated_hours, capacity_hours)
|
||||||
|
load_level = determine_load_level(load_percentage)
|
||||||
|
|
||||||
|
return WorkloadSummary(
|
||||||
|
allocated_hours=allocated_hours,
|
||||||
|
capacity_hours=capacity_hours,
|
||||||
|
load_percentage=load_percentage,
|
||||||
|
load_level=load_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_health_summary(db: Session) -> HealthSummary:
|
||||||
|
"""
|
||||||
|
Get aggregated project health summary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HealthSummary with project health breakdown
|
||||||
|
"""
|
||||||
|
health_service = HealthService(db)
|
||||||
|
dashboard = health_service.get_dashboard(status_filter="active")
|
||||||
|
|
||||||
|
return HealthSummary(
|
||||||
|
total_projects=dashboard.summary.total_projects,
|
||||||
|
healthy_count=dashboard.summary.healthy_count,
|
||||||
|
at_risk_count=dashboard.summary.at_risk_count,
|
||||||
|
critical_count=dashboard.summary.critical_count,
|
||||||
|
average_health_score=dashboard.summary.average_health_score,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=DashboardResponse)
|
||||||
|
async def get_dashboard(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get aggregated dashboard data for the current user.
|
||||||
|
|
||||||
|
Returns a single response containing:
|
||||||
|
- **task_stats**: User's task statistics (assigned, due this week, overdue, completion rate)
|
||||||
|
- **workload**: Current week workload summary (hours, load level)
|
||||||
|
- **health_summary**: Aggregated project health metrics
|
||||||
|
|
||||||
|
This endpoint combines multiple data sources into a single response
|
||||||
|
to minimize frontend API calls and ensure data consistency.
|
||||||
|
"""
|
||||||
|
# Calculate all dashboard components
|
||||||
|
task_stats = get_task_statistics(db, current_user.id)
|
||||||
|
workload = get_workload_summary(db, current_user)
|
||||||
|
health_summary = get_health_summary(db)
|
||||||
|
|
||||||
|
return DashboardResponse(
|
||||||
|
task_stats=task_stats,
|
||||||
|
workload=workload,
|
||||||
|
health_summary=health_summary,
|
||||||
|
)
|
||||||
@@ -37,6 +37,7 @@ from app.api.health import router as health_router
|
|||||||
from app.api.custom_fields import router as custom_fields_router
|
from app.api.custom_fields import router as custom_fields_router
|
||||||
from app.api.task_dependencies import router as task_dependencies_router
|
from app.api.task_dependencies import router as task_dependencies_router
|
||||||
from app.api.admin import encryption_keys as admin_encryption_keys_router
|
from app.api.admin import encryption_keys as admin_encryption_keys_router
|
||||||
|
from app.api.dashboard import router as dashboard_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -82,6 +83,7 @@ app.include_router(health_router)
|
|||||||
app.include_router(custom_fields_router)
|
app.include_router(custom_fields_router)
|
||||||
app.include_router(task_dependencies_router)
|
app.include_router(task_dependencies_router)
|
||||||
app.include_router(admin_encryption_keys_router.router)
|
app.include_router(admin_encryption_keys_router.router)
|
||||||
|
app.include_router(dashboard_router, prefix="/api/dashboard", tags=["Dashboard"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
72
backend/app/schemas/dashboard.py
Normal file
72
backend/app/schemas/dashboard.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Dashboard API schemas.
|
||||||
|
|
||||||
|
Defines response models for the dashboard endpoint that aggregates
|
||||||
|
task statistics, workload, and project health summaries.
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.schemas.workload import LoadLevel
|
||||||
|
|
||||||
|
|
||||||
|
class TaskStatistics(BaseModel):
|
||||||
|
"""User's task statistics for dashboard display.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
assigned_count: Total tasks assigned to user (not completed)
|
||||||
|
due_this_week: Tasks with due_date in current week
|
||||||
|
overdue_count: Tasks past due_date, not completed
|
||||||
|
completion_rate: Percentage of completed tasks (0-100)
|
||||||
|
"""
|
||||||
|
assigned_count: int
|
||||||
|
due_this_week: int
|
||||||
|
overdue_count: int
|
||||||
|
completion_rate: float
|
||||||
|
|
||||||
|
|
||||||
|
class WorkloadSummary(BaseModel):
|
||||||
|
"""User's workload summary for dashboard display.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
allocated_hours: Total estimated hours from tasks due this week
|
||||||
|
capacity_hours: User's weekly capacity
|
||||||
|
load_percentage: Percentage of capacity used
|
||||||
|
load_level: normal (<80%), warning (80-99%), overloaded (>=100%)
|
||||||
|
"""
|
||||||
|
allocated_hours: Decimal
|
||||||
|
capacity_hours: Decimal
|
||||||
|
load_percentage: Optional[Decimal] = None
|
||||||
|
load_level: LoadLevel
|
||||||
|
|
||||||
|
|
||||||
|
class HealthSummary(BaseModel):
|
||||||
|
"""Aggregated project health summary for dashboard display.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
total_projects: Total number of active projects
|
||||||
|
healthy_count: Projects with health_score >= 80
|
||||||
|
at_risk_count: Projects with health_score 50-79
|
||||||
|
critical_count: Projects with health_score < 50
|
||||||
|
average_health_score: Average health score across all projects
|
||||||
|
"""
|
||||||
|
total_projects: int
|
||||||
|
healthy_count: int
|
||||||
|
at_risk_count: int
|
||||||
|
critical_count: int
|
||||||
|
average_health_score: float
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardResponse(BaseModel):
|
||||||
|
"""Complete dashboard response aggregating all widgets.
|
||||||
|
|
||||||
|
Single endpoint response that combines:
|
||||||
|
- Task statistics for the current user
|
||||||
|
- Current week workload summary
|
||||||
|
- Project health summary
|
||||||
|
|
||||||
|
This minimizes frontend API calls and ensures data consistency.
|
||||||
|
"""
|
||||||
|
task_stats: TaskStatistics
|
||||||
|
workload: WorkloadSummary
|
||||||
|
health_summary: HealthSummary
|
||||||
635
backend/tests/test_dashboard.py
Normal file
635
backend/tests/test_dashboard.py
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
"""Tests for dashboard API and service functions."""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.models import User, Department, Space, Project, Task
|
||||||
|
from app.models.task_status import TaskStatus
|
||||||
|
from app.api.dashboard.router import (
|
||||||
|
get_task_statistics,
|
||||||
|
get_workload_summary,
|
||||||
|
get_health_summary,
|
||||||
|
)
|
||||||
|
from app.schemas.workload import LoadLevel
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskStatistics:
|
||||||
|
"""Tests for task statistics calculation."""
|
||||||
|
|
||||||
|
def setup_test_data(self, db):
|
||||||
|
"""Set up test data for task statistics tests."""
|
||||||
|
# Create department
|
||||||
|
dept = Department(
|
||||||
|
id="dept-dash-001",
|
||||||
|
name="Dashboard Test Department",
|
||||||
|
)
|
||||||
|
db.add(dept)
|
||||||
|
|
||||||
|
# Create test user
|
||||||
|
user = User(
|
||||||
|
id="user-dash-001",
|
||||||
|
email="dashboard@test.com",
|
||||||
|
name="Dashboard Test User",
|
||||||
|
department_id="dept-dash-001",
|
||||||
|
role_id="00000000-0000-0000-0000-000000000003",
|
||||||
|
capacity=40,
|
||||||
|
is_active=True,
|
||||||
|
is_system_admin=False,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
|
||||||
|
# Create space
|
||||||
|
space = Space(
|
||||||
|
id="space-dash-001",
|
||||||
|
name="Dashboard Test Space",
|
||||||
|
owner_id="00000000-0000-0000-0000-000000000001",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(space)
|
||||||
|
|
||||||
|
# Create project
|
||||||
|
project = Project(
|
||||||
|
id="project-dash-001",
|
||||||
|
space_id="space-dash-001",
|
||||||
|
title="Dashboard Test Project",
|
||||||
|
owner_id="00000000-0000-0000-0000-000000000001",
|
||||||
|
department_id="dept-dash-001",
|
||||||
|
security_level="department",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
|
||||||
|
# Create task statuses
|
||||||
|
status_todo = TaskStatus(
|
||||||
|
id="status-dash-todo",
|
||||||
|
project_id="project-dash-001",
|
||||||
|
name="To Do",
|
||||||
|
is_done=False,
|
||||||
|
)
|
||||||
|
db.add(status_todo)
|
||||||
|
|
||||||
|
status_done = TaskStatus(
|
||||||
|
id="status-dash-done",
|
||||||
|
project_id="project-dash-001",
|
||||||
|
name="Done",
|
||||||
|
is_done=True,
|
||||||
|
)
|
||||||
|
db.add(status_done)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"department": dept,
|
||||||
|
"user": user,
|
||||||
|
"space": space,
|
||||||
|
"project": project,
|
||||||
|
"status_todo": status_todo,
|
||||||
|
"status_done": status_done,
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_task(
|
||||||
|
self,
|
||||||
|
db,
|
||||||
|
data,
|
||||||
|
task_id,
|
||||||
|
done=False,
|
||||||
|
overdue=False,
|
||||||
|
due_this_week=False,
|
||||||
|
estimate=None,
|
||||||
|
):
|
||||||
|
"""Helper to create a task with optional characteristics."""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
if overdue:
|
||||||
|
due_date = now - timedelta(days=3)
|
||||||
|
elif due_this_week:
|
||||||
|
# Due in the middle of current week
|
||||||
|
due_date = now + timedelta(days=2)
|
||||||
|
else:
|
||||||
|
# Due next week
|
||||||
|
due_date = now + timedelta(days=10)
|
||||||
|
|
||||||
|
task = Task(
|
||||||
|
id=task_id,
|
||||||
|
project_id=data["project"].id,
|
||||||
|
title=f"Task {task_id}",
|
||||||
|
assignee_id=data["user"].id,
|
||||||
|
status_id=data["status_done"].id if done else data["status_todo"].id,
|
||||||
|
original_estimate=estimate,
|
||||||
|
due_date=due_date,
|
||||||
|
created_by="00000000-0000-0000-0000-000000000001",
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
db.commit()
|
||||||
|
return task
|
||||||
|
|
||||||
|
def test_empty_statistics(self, db):
|
||||||
|
"""User with no tasks should have zero counts."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
stats = get_task_statistics(db, data["user"].id)
|
||||||
|
|
||||||
|
assert stats.assigned_count == 0
|
||||||
|
assert stats.due_this_week == 0
|
||||||
|
assert stats.overdue_count == 0
|
||||||
|
assert stats.completion_rate == 0.0
|
||||||
|
|
||||||
|
def test_assigned_count(self, db):
|
||||||
|
"""Should count non-completed tasks assigned to user."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
# Create 3 tasks: 2 active, 1 completed
|
||||||
|
self.create_task(db, data, "task-1", done=False)
|
||||||
|
self.create_task(db, data, "task-2", done=False)
|
||||||
|
self.create_task(db, data, "task-3", done=True)
|
||||||
|
|
||||||
|
stats = get_task_statistics(db, data["user"].id)
|
||||||
|
|
||||||
|
assert stats.assigned_count == 2 # Only non-completed
|
||||||
|
|
||||||
|
def test_due_this_week_count(self, db):
|
||||||
|
"""Should count tasks due this week."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
# Create tasks with different due dates
|
||||||
|
self.create_task(db, data, "task-1", due_this_week=True)
|
||||||
|
self.create_task(db, data, "task-2", due_this_week=True)
|
||||||
|
self.create_task(db, data, "task-3", due_this_week=False) # Next week
|
||||||
|
|
||||||
|
stats = get_task_statistics(db, data["user"].id)
|
||||||
|
|
||||||
|
assert stats.due_this_week == 2
|
||||||
|
|
||||||
|
def test_overdue_count(self, db):
|
||||||
|
"""Should count overdue tasks."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
# Create overdue and non-overdue tasks
|
||||||
|
self.create_task(db, data, "task-1", overdue=True)
|
||||||
|
self.create_task(db, data, "task-2", overdue=True)
|
||||||
|
self.create_task(db, data, "task-3", overdue=False)
|
||||||
|
|
||||||
|
stats = get_task_statistics(db, data["user"].id)
|
||||||
|
|
||||||
|
assert stats.overdue_count == 2
|
||||||
|
|
||||||
|
def test_overdue_completed_not_counted(self, db):
|
||||||
|
"""Completed overdue tasks should not be counted as overdue."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
# Create overdue task that is completed
|
||||||
|
self.create_task(db, data, "task-1", overdue=True, done=True)
|
||||||
|
self.create_task(db, data, "task-2", overdue=True, done=False)
|
||||||
|
|
||||||
|
stats = get_task_statistics(db, data["user"].id)
|
||||||
|
|
||||||
|
assert stats.overdue_count == 1
|
||||||
|
|
||||||
|
def test_completion_rate(self, db):
|
||||||
|
"""Should calculate correct completion rate."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
# Create 4 tasks: 1 completed, 3 active = 25%
|
||||||
|
self.create_task(db, data, "task-1", done=True)
|
||||||
|
self.create_task(db, data, "task-2", done=False)
|
||||||
|
self.create_task(db, data, "task-3", done=False)
|
||||||
|
self.create_task(db, data, "task-4", done=False)
|
||||||
|
|
||||||
|
stats = get_task_statistics(db, data["user"].id)
|
||||||
|
|
||||||
|
assert stats.completion_rate == 25.0
|
||||||
|
|
||||||
|
def test_deleted_tasks_excluded(self, db):
|
||||||
|
"""Soft-deleted tasks should not be counted."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
# Create normal task
|
||||||
|
self.create_task(db, data, "task-1")
|
||||||
|
|
||||||
|
# Create deleted task
|
||||||
|
deleted_task = Task(
|
||||||
|
id="task-deleted",
|
||||||
|
project_id=data["project"].id,
|
||||||
|
title="Deleted Task",
|
||||||
|
assignee_id=data["user"].id,
|
||||||
|
status_id=data["status_todo"].id,
|
||||||
|
due_date=datetime.utcnow() - timedelta(days=5), # Overdue
|
||||||
|
created_by="00000000-0000-0000-0000-000000000001",
|
||||||
|
is_deleted=True,
|
||||||
|
deleted_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(deleted_task)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
stats = get_task_statistics(db, data["user"].id)
|
||||||
|
|
||||||
|
assert stats.assigned_count == 1
|
||||||
|
assert stats.overdue_count == 0 # Deleted task not counted
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkloadSummary:
|
||||||
|
"""Tests for workload summary calculation."""
|
||||||
|
|
||||||
|
def setup_test_data(self, db):
|
||||||
|
"""Set up test data for workload summary tests."""
|
||||||
|
# Create department
|
||||||
|
dept = Department(
|
||||||
|
id="dept-wl-001",
|
||||||
|
name="Workload Test Department",
|
||||||
|
)
|
||||||
|
db.add(dept)
|
||||||
|
|
||||||
|
# Create test user with 40h capacity
|
||||||
|
user = User(
|
||||||
|
id="user-wl-001",
|
||||||
|
email="workload@test.com",
|
||||||
|
name="Workload Test User",
|
||||||
|
department_id="dept-wl-001",
|
||||||
|
role_id="00000000-0000-0000-0000-000000000003",
|
||||||
|
capacity=40,
|
||||||
|
is_active=True,
|
||||||
|
is_system_admin=False,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
|
||||||
|
# Create space
|
||||||
|
space = Space(
|
||||||
|
id="space-wl-001",
|
||||||
|
name="Workload Test Space",
|
||||||
|
owner_id="00000000-0000-0000-0000-000000000001",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(space)
|
||||||
|
|
||||||
|
# Create project
|
||||||
|
project = Project(
|
||||||
|
id="project-wl-001",
|
||||||
|
space_id="space-wl-001",
|
||||||
|
title="Workload Test Project",
|
||||||
|
owner_id="00000000-0000-0000-0000-000000000001",
|
||||||
|
department_id="dept-wl-001",
|
||||||
|
security_level="department",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
|
||||||
|
# Create task status
|
||||||
|
status_todo = TaskStatus(
|
||||||
|
id="status-wl-todo",
|
||||||
|
project_id="project-wl-001",
|
||||||
|
name="To Do",
|
||||||
|
is_done=False,
|
||||||
|
)
|
||||||
|
db.add(status_todo)
|
||||||
|
|
||||||
|
status_done = TaskStatus(
|
||||||
|
id="status-wl-done",
|
||||||
|
project_id="project-wl-001",
|
||||||
|
name="Done",
|
||||||
|
is_done=True,
|
||||||
|
)
|
||||||
|
db.add(status_done)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"department": dept,
|
||||||
|
"user": user,
|
||||||
|
"space": space,
|
||||||
|
"project": project,
|
||||||
|
"status_todo": status_todo,
|
||||||
|
"status_done": status_done,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_empty_workload(self, db):
|
||||||
|
"""User with no tasks should have zero allocated hours."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
workload = get_workload_summary(db, data["user"])
|
||||||
|
|
||||||
|
assert workload.allocated_hours == Decimal("0")
|
||||||
|
assert workload.capacity_hours == Decimal("40")
|
||||||
|
assert workload.load_percentage == Decimal("0.00")
|
||||||
|
assert workload.load_level == LoadLevel.NORMAL
|
||||||
|
|
||||||
|
def test_workload_with_tasks(self, db):
|
||||||
|
"""Should calculate correct allocated hours."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
# Create tasks due this week with estimates
|
||||||
|
now = datetime.utcnow()
|
||||||
|
due_date = now + timedelta(days=2)
|
||||||
|
|
||||||
|
task1 = Task(
|
||||||
|
id="task-wl-1",
|
||||||
|
project_id=data["project"].id,
|
||||||
|
title="Task 1",
|
||||||
|
assignee_id=data["user"].id,
|
||||||
|
status_id=data["status_todo"].id,
|
||||||
|
original_estimate=Decimal("16"),
|
||||||
|
due_date=due_date,
|
||||||
|
created_by="00000000-0000-0000-0000-000000000001",
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
db.add(task1)
|
||||||
|
|
||||||
|
task2 = Task(
|
||||||
|
id="task-wl-2",
|
||||||
|
project_id=data["project"].id,
|
||||||
|
title="Task 2",
|
||||||
|
assignee_id=data["user"].id,
|
||||||
|
status_id=data["status_todo"].id,
|
||||||
|
original_estimate=Decimal("16"),
|
||||||
|
due_date=due_date,
|
||||||
|
created_by="00000000-0000-0000-0000-000000000001",
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
db.add(task2)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
workload = get_workload_summary(db, data["user"])
|
||||||
|
|
||||||
|
assert workload.allocated_hours == Decimal("32")
|
||||||
|
assert workload.load_percentage == Decimal("80.00")
|
||||||
|
assert workload.load_level == LoadLevel.WARNING
|
||||||
|
|
||||||
|
def test_workload_overloaded(self, db):
|
||||||
|
"""User with more than capacity should be overloaded."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
due_date = now + timedelta(days=2)
|
||||||
|
|
||||||
|
# Create task with 48h estimate (> 40h capacity)
|
||||||
|
task = Task(
|
||||||
|
id="task-wl-over",
|
||||||
|
project_id=data["project"].id,
|
||||||
|
title="Big Task",
|
||||||
|
assignee_id=data["user"].id,
|
||||||
|
status_id=data["status_todo"].id,
|
||||||
|
original_estimate=Decimal("48"),
|
||||||
|
due_date=due_date,
|
||||||
|
created_by="00000000-0000-0000-0000-000000000001",
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
workload = get_workload_summary(db, data["user"])
|
||||||
|
|
||||||
|
assert workload.allocated_hours == Decimal("48")
|
||||||
|
assert workload.load_percentage == Decimal("120.00")
|
||||||
|
assert workload.load_level == LoadLevel.OVERLOADED
|
||||||
|
|
||||||
|
def test_completed_tasks_excluded(self, db):
|
||||||
|
"""Completed tasks should not count toward workload."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
due_date = now + timedelta(days=2)
|
||||||
|
|
||||||
|
# Create completed task
|
||||||
|
task = Task(
|
||||||
|
id="task-wl-done",
|
||||||
|
project_id=data["project"].id,
|
||||||
|
title="Done Task",
|
||||||
|
assignee_id=data["user"].id,
|
||||||
|
status_id=data["status_done"].id,
|
||||||
|
original_estimate=Decimal("24"),
|
||||||
|
due_date=due_date,
|
||||||
|
created_by="00000000-0000-0000-0000-000000000001",
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
workload = get_workload_summary(db, data["user"])
|
||||||
|
|
||||||
|
assert workload.allocated_hours == Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealthSummary:
|
||||||
|
"""Tests for health summary aggregation."""
|
||||||
|
|
||||||
|
def setup_test_data(self, db):
|
||||||
|
"""Set up test data for health summary tests."""
|
||||||
|
# Create department
|
||||||
|
dept = Department(
|
||||||
|
id="dept-hs-001",
|
||||||
|
name="Health Summary Test Department",
|
||||||
|
)
|
||||||
|
db.add(dept)
|
||||||
|
|
||||||
|
# Create space
|
||||||
|
space = Space(
|
||||||
|
id="space-hs-001",
|
||||||
|
name="Health Summary Test Space",
|
||||||
|
owner_id="00000000-0000-0000-0000-000000000001",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(space)
|
||||||
|
|
||||||
|
# Create active project
|
||||||
|
project = Project(
|
||||||
|
id="project-hs-001",
|
||||||
|
space_id="space-hs-001",
|
||||||
|
title="Health Test Project",
|
||||||
|
owner_id="00000000-0000-0000-0000-000000000001",
|
||||||
|
department_id="dept-hs-001",
|
||||||
|
security_level="department",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"department": dept,
|
||||||
|
"space": space,
|
||||||
|
"project": project,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_health_summary_structure(self, db):
|
||||||
|
"""Health summary should have correct structure."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
summary = get_health_summary(db)
|
||||||
|
|
||||||
|
assert summary.total_projects >= 1
|
||||||
|
assert summary.healthy_count >= 0
|
||||||
|
assert summary.at_risk_count >= 0
|
||||||
|
assert summary.critical_count >= 0
|
||||||
|
assert summary.average_health_score >= 0
|
||||||
|
assert summary.average_health_score <= 100
|
||||||
|
|
||||||
|
|
||||||
|
class TestDashboardAPI:
|
||||||
|
"""Tests for dashboard API endpoint."""
|
||||||
|
|
||||||
|
def setup_test_data(self, db):
|
||||||
|
"""Set up test data for dashboard API tests."""
|
||||||
|
# Create department
|
||||||
|
dept = Department(
|
||||||
|
id="dept-api-dash-001",
|
||||||
|
name="API Dashboard Test Department",
|
||||||
|
)
|
||||||
|
db.add(dept)
|
||||||
|
|
||||||
|
# Create space
|
||||||
|
space = Space(
|
||||||
|
id="space-api-dash-001",
|
||||||
|
name="API Dashboard Test Space",
|
||||||
|
owner_id="00000000-0000-0000-0000-000000000001",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(space)
|
||||||
|
|
||||||
|
# Create project
|
||||||
|
project = Project(
|
||||||
|
id="project-api-dash-001",
|
||||||
|
space_id="space-api-dash-001",
|
||||||
|
title="API Dashboard Test Project",
|
||||||
|
owner_id="00000000-0000-0000-0000-000000000001",
|
||||||
|
department_id="dept-api-dash-001",
|
||||||
|
security_level="department",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
|
||||||
|
# Create task status
|
||||||
|
status_todo = TaskStatus(
|
||||||
|
id="status-api-dash-todo",
|
||||||
|
project_id="project-api-dash-001",
|
||||||
|
name="To Do",
|
||||||
|
is_done=False,
|
||||||
|
)
|
||||||
|
db.add(status_todo)
|
||||||
|
|
||||||
|
# Create a task for the admin user
|
||||||
|
now = datetime.utcnow()
|
||||||
|
task = Task(
|
||||||
|
id="task-api-dash-001",
|
||||||
|
project_id="project-api-dash-001",
|
||||||
|
title="Admin Task",
|
||||||
|
assignee_id="00000000-0000-0000-0000-000000000001",
|
||||||
|
status_id="status-api-dash-todo",
|
||||||
|
original_estimate=Decimal("8"),
|
||||||
|
due_date=now + timedelta(days=2),
|
||||||
|
created_by="00000000-0000-0000-0000-000000000001",
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"department": dept,
|
||||||
|
"space": space,
|
||||||
|
"project": project,
|
||||||
|
"task": task,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_get_dashboard(self, client, db, admin_token):
|
||||||
|
"""Should return complete dashboard data."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/dashboard",
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
# Check structure
|
||||||
|
assert "task_stats" in result
|
||||||
|
assert "workload" in result
|
||||||
|
assert "health_summary" in result
|
||||||
|
|
||||||
|
def test_dashboard_task_stats_fields(self, client, db, admin_token):
|
||||||
|
"""Dashboard task_stats should include all expected fields."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/dashboard",
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
task_stats = response.json()["task_stats"]
|
||||||
|
|
||||||
|
assert "assigned_count" in task_stats
|
||||||
|
assert "due_this_week" in task_stats
|
||||||
|
assert "overdue_count" in task_stats
|
||||||
|
assert "completion_rate" in task_stats
|
||||||
|
|
||||||
|
def test_dashboard_workload_fields(self, client, db, admin_token):
|
||||||
|
"""Dashboard workload should include all expected fields."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/dashboard",
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
workload = response.json()["workload"]
|
||||||
|
|
||||||
|
assert "allocated_hours" in workload
|
||||||
|
assert "capacity_hours" in workload
|
||||||
|
assert "load_percentage" in workload
|
||||||
|
assert "load_level" in workload
|
||||||
|
|
||||||
|
def test_dashboard_health_summary_fields(self, client, db, admin_token):
|
||||||
|
"""Dashboard health_summary should include all expected fields."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/dashboard",
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
health_summary = response.json()["health_summary"]
|
||||||
|
|
||||||
|
assert "total_projects" in health_summary
|
||||||
|
assert "healthy_count" in health_summary
|
||||||
|
assert "at_risk_count" in health_summary
|
||||||
|
assert "critical_count" in health_summary
|
||||||
|
assert "average_health_score" in health_summary
|
||||||
|
|
||||||
|
def test_dashboard_unauthorized(self, client, db):
|
||||||
|
"""Unauthenticated requests should fail."""
|
||||||
|
response = client.get("/api/dashboard")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_dashboard_with_user_tasks(self, client, db, admin_token):
|
||||||
|
"""Dashboard should reflect user's tasks correctly."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/dashboard",
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
# Admin has 1 task assigned (created in setup)
|
||||||
|
assert result["task_stats"]["assigned_count"] >= 1
|
||||||
|
assert result["task_stats"]["due_this_week"] >= 1
|
||||||
|
|
||||||
|
def test_dashboard_workload_load_level_values(self, client, db, admin_token):
|
||||||
|
"""Workload load_level should be a valid enum value."""
|
||||||
|
data = self.setup_test_data(db)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/dashboard",
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
load_level = response.json()["workload"]["load_level"]
|
||||||
|
|
||||||
|
assert load_level in ["normal", "warning", "overloaded", "unavailable"]
|
||||||
216
frontend/src/components/dashboard/HealthSummaryWidget.tsx
Normal file
216
frontend/src/components/dashboard/HealthSummaryWidget.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { HealthSummary } from '../../services/dashboard'
|
||||||
|
|
||||||
|
interface HealthSummaryWidgetProps {
|
||||||
|
health: HealthSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health score color helper
|
||||||
|
function getHealthScoreColor(score: number): string {
|
||||||
|
if (score >= 80) return '#4caf50' // Green
|
||||||
|
if (score >= 60) return '#ff9800' // Orange
|
||||||
|
if (score >= 40) return '#ff5722' // Deep Orange
|
||||||
|
return '#f44336' // Red
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HealthSummaryWidget({ health }: HealthSummaryWidgetProps) {
|
||||||
|
const scoreColor = getHealthScoreColor(health.average_health_score)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<div style={styles.header}>
|
||||||
|
<h3 style={styles.title}>Project Health</h3>
|
||||||
|
<span style={styles.totalBadge}>
|
||||||
|
{health.total_projects} projects
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.content}>
|
||||||
|
{/* Average Score Display */}
|
||||||
|
<div style={styles.scoreSection}>
|
||||||
|
<div style={styles.scoreCircle}>
|
||||||
|
<svg width="80" height="80" viewBox="0 0 80 80">
|
||||||
|
{/* Background circle */}
|
||||||
|
<circle
|
||||||
|
cx="40"
|
||||||
|
cy="40"
|
||||||
|
r="35"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e0e0e0"
|
||||||
|
strokeWidth="6"
|
||||||
|
/>
|
||||||
|
{/* Progress circle */}
|
||||||
|
<circle
|
||||||
|
cx="40"
|
||||||
|
cy="40"
|
||||||
|
r="35"
|
||||||
|
fill="none"
|
||||||
|
stroke={scoreColor}
|
||||||
|
strokeWidth="6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={`${(health.average_health_score / 100) * 220} 220`}
|
||||||
|
transform="rotate(-90 40 40)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div style={styles.scoreText}>
|
||||||
|
<span style={{ ...styles.scoreValue, color: scoreColor }}>
|
||||||
|
{Math.round(health.average_health_score)}
|
||||||
|
</span>
|
||||||
|
<span style={styles.scoreLabel}>Avg Score</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Breakdown */}
|
||||||
|
<div style={styles.breakdown}>
|
||||||
|
<div style={styles.breakdownItem}>
|
||||||
|
<span style={{ ...styles.statusDot, backgroundColor: '#4caf50' }} />
|
||||||
|
<span style={styles.breakdownLabel}>Healthy</span>
|
||||||
|
<span style={styles.breakdownValue}>{health.healthy_count}</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.breakdownItem}>
|
||||||
|
<span style={{ ...styles.statusDot, backgroundColor: '#ff9800' }} />
|
||||||
|
<span style={styles.breakdownLabel}>At Risk</span>
|
||||||
|
<span style={styles.breakdownValue}>{health.at_risk_count}</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.breakdownItem}>
|
||||||
|
<span style={{ ...styles.statusDot, backgroundColor: '#f44336' }} />
|
||||||
|
<span style={styles.breakdownLabel}>Critical</span>
|
||||||
|
<span style={styles.breakdownValue}>{health.critical_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Blockers Info */}
|
||||||
|
{health.projects_with_blockers > 0 && (
|
||||||
|
<div style={styles.blockersInfo}>
|
||||||
|
<span style={styles.blockersIcon}>!</span>
|
||||||
|
<span style={styles.blockersText}>
|
||||||
|
{health.projects_with_blockers} project{health.projects_with_blockers > 1 ? 's have' : ' has'} blockers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: { [key: string]: React.CSSProperties } = {
|
||||||
|
card: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||||
|
padding: '20px',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
totalBadge: {
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#666',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '16px',
|
||||||
|
},
|
||||||
|
scoreSection: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '24px',
|
||||||
|
},
|
||||||
|
scoreCircle: {
|
||||||
|
position: 'relative',
|
||||||
|
width: '80px',
|
||||||
|
height: '80px',
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
scoreText: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
scoreValue: {
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 700,
|
||||||
|
display: 'block',
|
||||||
|
lineHeight: 1,
|
||||||
|
},
|
||||||
|
scoreLabel: {
|
||||||
|
fontSize: '9px',
|
||||||
|
color: '#666',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
},
|
||||||
|
breakdown: {
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '10px',
|
||||||
|
},
|
||||||
|
breakdownItem: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
breakdownLabel: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
breakdownValue: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#333',
|
||||||
|
minWidth: '24px',
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
blockersInfo: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '10px 12px',
|
||||||
|
backgroundColor: '#fff3e0',
|
||||||
|
borderRadius: '6px',
|
||||||
|
},
|
||||||
|
blockersIcon: {
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: '#ff9800',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 700,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
blockersText: {
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#e65100',
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HealthSummaryWidget
|
||||||
136
frontend/src/components/dashboard/QuickActions.tsx
Normal file
136
frontend/src/components/dashboard/QuickActions.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface QuickActionsProps {
|
||||||
|
isAdmin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionItem {
|
||||||
|
to: string
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
color: string
|
||||||
|
description: string
|
||||||
|
adminOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions: ActionItem[] = [
|
||||||
|
{
|
||||||
|
to: '/spaces',
|
||||||
|
label: 'Spaces',
|
||||||
|
icon: '\u25a0', // Square icon
|
||||||
|
color: '#2196f3',
|
||||||
|
description: 'Browse projects',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/workload',
|
||||||
|
label: 'Workload',
|
||||||
|
icon: '\u25b2', // Triangle icon
|
||||||
|
color: '#9c27b0',
|
||||||
|
description: 'View team capacity',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/health',
|
||||||
|
label: 'Health',
|
||||||
|
icon: '\u2665', // Heart icon
|
||||||
|
color: '#4caf50',
|
||||||
|
description: 'Project status',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/audit',
|
||||||
|
label: 'Audit',
|
||||||
|
icon: '\u25cf', // Circle icon
|
||||||
|
color: '#ff9800',
|
||||||
|
description: 'Activity logs',
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function QuickActions({ isAdmin = false }: QuickActionsProps) {
|
||||||
|
const visibleActions = actions.filter((action) => !action.adminOnly || isAdmin)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<h3 style={styles.title}>Quick Actions</h3>
|
||||||
|
<div style={styles.grid}>
|
||||||
|
{visibleActions.map((action) => (
|
||||||
|
<Link
|
||||||
|
key={action.to}
|
||||||
|
to={action.to}
|
||||||
|
style={styles.actionLink}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.iconContainer,
|
||||||
|
backgroundColor: `${action.color}15`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ ...styles.icon, color: action.color }}>
|
||||||
|
{action.icon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style={styles.label}>{action.label}</span>
|
||||||
|
<span style={styles.description}>{action.description}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: { [key: string]: React.CSSProperties } = {
|
||||||
|
card: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||||
|
padding: '20px',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
margin: '0 0 16px 0',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
|
||||||
|
gap: '12px',
|
||||||
|
},
|
||||||
|
actionLink: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '16px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
textDecoration: 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: '8px',
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
fontSize: '20px',
|
||||||
|
lineHeight: 1,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#666',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuickActions
|
||||||
90
frontend/src/components/dashboard/StatisticsCard.tsx
Normal file
90
frontend/src/components/dashboard/StatisticsCard.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface StatisticsCardProps {
|
||||||
|
icon: React.ReactNode
|
||||||
|
value: number | string
|
||||||
|
label: string
|
||||||
|
color?: string
|
||||||
|
suffix?: string
|
||||||
|
highlight?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatisticsCard({
|
||||||
|
icon,
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
color = '#333',
|
||||||
|
suffix = '',
|
||||||
|
highlight = false,
|
||||||
|
}: StatisticsCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.card,
|
||||||
|
borderLeft: highlight ? `4px solid ${color}` : '4px solid transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ ...styles.iconContainer, backgroundColor: `${color}15` }}>
|
||||||
|
<span style={{ ...styles.icon, color }}>{icon}</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.content}>
|
||||||
|
<span style={{ ...styles.value, color }}>
|
||||||
|
{value}
|
||||||
|
{suffix && <span style={styles.suffix}>{suffix}</span>}
|
||||||
|
</span>
|
||||||
|
<span style={styles.label}>{label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: { [key: string]: React.CSSProperties } = {
|
||||||
|
card: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||||
|
padding: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
transition: 'box-shadow 0.2s ease',
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
fontSize: '24px',
|
||||||
|
lineHeight: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '4px',
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontSize: '28px',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
},
|
||||||
|
suffix: {
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
marginLeft: '2px',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#666',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatisticsCard
|
||||||
148
frontend/src/components/dashboard/WorkloadWidget.tsx
Normal file
148
frontend/src/components/dashboard/WorkloadWidget.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { WorkloadSummary, LoadLevel } from '../../services/dashboard'
|
||||||
|
|
||||||
|
interface WorkloadWidgetProps {
|
||||||
|
workload: WorkloadSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load level configuration
|
||||||
|
const loadLevelConfig: Record<LoadLevel, { color: string; bgColor: string; label: string }> = {
|
||||||
|
normal: { color: '#4caf50', bgColor: '#e8f5e9', label: 'Normal' },
|
||||||
|
warning: { color: '#ff9800', bgColor: '#fff3e0', label: 'Warning' },
|
||||||
|
overloaded: { color: '#f44336', bgColor: '#ffebee', label: 'Overloaded' },
|
||||||
|
unavailable: { color: '#9e9e9e', bgColor: '#f5f5f5', label: 'Unavailable' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkloadWidget({ workload }: WorkloadWidgetProps) {
|
||||||
|
const config = loadLevelConfig[workload.load_level]
|
||||||
|
const percentage = Math.min(workload.load_percentage, 100)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<div style={styles.header}>
|
||||||
|
<h3 style={styles.title}>My Workload</h3>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
...styles.badge,
|
||||||
|
color: config.color,
|
||||||
|
backgroundColor: config.bgColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.content}>
|
||||||
|
<div style={styles.hoursDisplay}>
|
||||||
|
<span style={styles.hoursValue}>{workload.allocated_hours}h</span>
|
||||||
|
<span style={styles.hoursDivider}>/</span>
|
||||||
|
<span style={styles.hoursCapacity}>{workload.capacity_hours}h</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.progressContainer}>
|
||||||
|
<div style={styles.progressBar}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.progressFill,
|
||||||
|
width: `${percentage}%`,
|
||||||
|
backgroundColor: config.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span style={{ ...styles.percentage, color: config.color }}>
|
||||||
|
{workload.load_percentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={styles.description}>
|
||||||
|
{workload.load_level === 'normal' && 'You have capacity for additional tasks this week.'}
|
||||||
|
{workload.load_level === 'warning' && 'You are approaching full capacity this week.'}
|
||||||
|
{workload.load_level === 'overloaded' && 'You are over capacity. Consider reassigning tasks.'}
|
||||||
|
{workload.load_level === 'unavailable' && 'Workload data is not available.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: { [key: string]: React.CSSProperties } = {
|
||||||
|
card: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||||
|
padding: '20px',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px',
|
||||||
|
},
|
||||||
|
hoursDisplay: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
gap: '4px',
|
||||||
|
},
|
||||||
|
hoursValue: {
|
||||||
|
fontSize: '32px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
hoursDivider: {
|
||||||
|
fontSize: '20px',
|
||||||
|
color: '#999',
|
||||||
|
margin: '0 4px',
|
||||||
|
},
|
||||||
|
hoursCapacity: {
|
||||||
|
fontSize: '20px',
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
progressContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
flex: 1,
|
||||||
|
height: '8px',
|
||||||
|
backgroundColor: '#e0e0e0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
|
},
|
||||||
|
percentage: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
minWidth: '45px',
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#666',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkloadWidget
|
||||||
4
frontend/src/components/dashboard/index.ts
Normal file
4
frontend/src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { StatisticsCard } from './StatisticsCard'
|
||||||
|
export { WorkloadWidget } from './WorkloadWidget'
|
||||||
|
export { HealthSummaryWidget } from './HealthSummaryWidget'
|
||||||
|
export { QuickActions } from './QuickActions'
|
||||||
@@ -1,28 +1,171 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { dashboardApi, DashboardResponse } from '../services/dashboard'
|
||||||
|
import {
|
||||||
|
StatisticsCard,
|
||||||
|
WorkloadWidget,
|
||||||
|
HealthSummaryWidget,
|
||||||
|
QuickActions,
|
||||||
|
} from '../components/dashboard'
|
||||||
|
import { Skeleton } from '../components/Skeleton'
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
const [data, setData] = useState<DashboardResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchDashboard = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const response = await dashboardApi.getDashboard()
|
||||||
|
setData(response)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load dashboard data. Please try again.')
|
||||||
|
console.error('Dashboard fetch error:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDashboard()
|
||||||
|
}, [fetchDashboard])
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<div style={styles.welcomeSection}>
|
||||||
|
<Skeleton variant="text" width={200} height={28} />
|
||||||
|
<Skeleton variant="text" width={150} height={16} style={{ marginTop: 8 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics Cards Skeleton */}
|
||||||
|
<div style={styles.statsGrid}>
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} style={styles.skeletonCard}>
|
||||||
|
<Skeleton variant="circle" width={48} height={48} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Skeleton variant="text" width="60%" height={28} />
|
||||||
|
<Skeleton variant="text" width="80%" height={14} style={{ marginTop: 4 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Widgets Skeleton */}
|
||||||
|
<div style={styles.widgetsGrid}>
|
||||||
|
<div style={styles.skeletonWidget}>
|
||||||
|
<Skeleton variant="text" width={120} height={20} />
|
||||||
|
<Skeleton variant="text" width="100%" height={32} style={{ marginTop: 16 }} />
|
||||||
|
<Skeleton variant="rect" width="100%" height={8} style={{ marginTop: 12, borderRadius: 4 }} />
|
||||||
|
</div>
|
||||||
|
<div style={styles.skeletonWidget}>
|
||||||
|
<Skeleton variant="text" width={120} height={20} />
|
||||||
|
<div style={{ display: 'flex', gap: 24, marginTop: 16 }}>
|
||||||
|
<Skeleton variant="circle" width={80} height={80} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Skeleton variant="text" width="100%" height={16} />
|
||||||
|
<Skeleton variant="text" width="100%" height={16} style={{ marginTop: 8 }} />
|
||||||
|
<Skeleton variant="text" width="100%" height={16} style={{ marginTop: 8 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions Skeleton */}
|
||||||
|
<div style={styles.skeletonWidget}>
|
||||||
|
<Skeleton variant="text" width={120} height={20} />
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginTop: 16 }}>
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Skeleton key={i} variant="rect" width={100} height={80} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<div style={styles.welcomeSection}>
|
||||||
|
<h1 style={styles.welcomeTitle}>Welcome, {user?.name}!</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.errorCard}>
|
||||||
|
<div style={styles.errorIcon}>!</div>
|
||||||
|
<h3 style={styles.errorTitle}>Unable to Load Dashboard</h3>
|
||||||
|
<p style={styles.errorMessage}>{error}</p>
|
||||||
|
<button
|
||||||
|
style={styles.retryButton}
|
||||||
|
onClick={fetchDashboard}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success state
|
||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<div style={styles.welcomeCard}>
|
{/* Welcome Section */}
|
||||||
<h2>Welcome, {user?.name}!</h2>
|
<div style={styles.welcomeSection}>
|
||||||
<p>Email: {user?.email}</p>
|
<h1 style={styles.welcomeTitle}>Welcome, {user?.name}!</h1>
|
||||||
<p>Role: {user?.role || 'No role assigned'}</p>
|
<p style={styles.welcomeSubtitle}>
|
||||||
{user?.is_system_admin && (
|
Here is your work overview for today
|
||||||
<p style={styles.adminNote}>
|
|
||||||
You have system administrator privileges.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={styles.infoCard}>
|
|
||||||
<h3>Getting Started</h3>
|
|
||||||
<p>
|
|
||||||
This is the Project Control system dashboard. Features will be
|
|
||||||
added as development progresses.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<div style={styles.statsGrid}>
|
||||||
|
<StatisticsCard
|
||||||
|
icon="✓"
|
||||||
|
value={data.task_stats.assigned_count}
|
||||||
|
label="My Tasks"
|
||||||
|
color="#2196f3"
|
||||||
|
/>
|
||||||
|
<StatisticsCard
|
||||||
|
icon="⏰"
|
||||||
|
value={data.task_stats.due_this_week}
|
||||||
|
label="Due This Week"
|
||||||
|
color="#ff9800"
|
||||||
|
highlight={data.task_stats.due_this_week > 0}
|
||||||
|
/>
|
||||||
|
<StatisticsCard
|
||||||
|
icon="⚠"
|
||||||
|
value={data.task_stats.overdue_count}
|
||||||
|
label="Overdue"
|
||||||
|
color="#f44336"
|
||||||
|
highlight={data.task_stats.overdue_count > 0}
|
||||||
|
/>
|
||||||
|
<StatisticsCard
|
||||||
|
icon="✅"
|
||||||
|
value={data.task_stats.completion_rate}
|
||||||
|
label="Completion Rate"
|
||||||
|
color="#4caf50"
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Widgets Grid */}
|
||||||
|
<div style={styles.widgetsGrid}>
|
||||||
|
<WorkloadWidget workload={data.workload} />
|
||||||
|
<HealthSummaryWidget health={data.health_summary} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<QuickActions isAdmin={user?.is_system_admin} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -32,23 +175,93 @@ const styles: { [key: string]: React.CSSProperties } = {
|
|||||||
padding: '24px',
|
padding: '24px',
|
||||||
maxWidth: '1200px',
|
maxWidth: '1200px',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '24px',
|
||||||
},
|
},
|
||||||
welcomeCard: {
|
welcomeSection: {
|
||||||
|
marginBottom: '8px',
|
||||||
|
},
|
||||||
|
welcomeTitle: {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
welcomeSubtitle: {
|
||||||
|
margin: '8px 0 0 0',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
statsGrid: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
||||||
|
gap: '16px',
|
||||||
|
},
|
||||||
|
widgetsGrid: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||||
|
gap: '16px',
|
||||||
|
},
|
||||||
|
skeletonCard: {
|
||||||
backgroundColor: 'white',
|
backgroundColor: 'white',
|
||||||
padding: '24px',
|
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||||
marginBottom: '24px',
|
padding: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
},
|
},
|
||||||
adminNote: {
|
skeletonWidget: {
|
||||||
color: '#0066cc',
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||||
|
padding: '20px',
|
||||||
|
},
|
||||||
|
errorCard: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||||
|
padding: '40px',
|
||||||
|
textAlign: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
},
|
||||||
|
errorIcon: {
|
||||||
|
width: '60px',
|
||||||
|
height: '60px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: '#ffebee',
|
||||||
|
color: '#f44336',
|
||||||
|
fontSize: '32px',
|
||||||
|
fontWeight: 700,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
errorMessage: {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#666',
|
||||||
|
maxWidth: '400px',
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
padding: '10px 24px',
|
||||||
|
fontSize: '14px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
marginTop: '12px',
|
color: 'white',
|
||||||
},
|
backgroundColor: '#2196f3',
|
||||||
infoCard: {
|
border: 'none',
|
||||||
backgroundColor: 'white',
|
borderRadius: '6px',
|
||||||
padding: '24px',
|
cursor: 'pointer',
|
||||||
borderRadius: '8px',
|
transition: 'background-color 0.2s ease',
|
||||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
46
frontend/src/services/dashboard.ts
Normal file
46
frontend/src/services/dashboard.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import api from './api'
|
||||||
|
|
||||||
|
// Types for Dashboard API responses
|
||||||
|
export type LoadLevel = 'normal' | 'warning' | 'overloaded' | 'unavailable'
|
||||||
|
|
||||||
|
export interface TaskStatistics {
|
||||||
|
assigned_count: number
|
||||||
|
due_this_week: number
|
||||||
|
overdue_count: number
|
||||||
|
completion_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkloadSummary {
|
||||||
|
allocated_hours: number
|
||||||
|
capacity_hours: number
|
||||||
|
load_percentage: number
|
||||||
|
load_level: LoadLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthSummary {
|
||||||
|
total_projects: number
|
||||||
|
healthy_count: number
|
||||||
|
at_risk_count: number
|
||||||
|
critical_count: number
|
||||||
|
average_health_score: number
|
||||||
|
projects_with_blockers: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardResponse {
|
||||||
|
task_stats: TaskStatistics
|
||||||
|
workload: WorkloadSummary
|
||||||
|
health_summary: HealthSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
// API functions
|
||||||
|
export const dashboardApi = {
|
||||||
|
/**
|
||||||
|
* Get aggregated dashboard data for the current user
|
||||||
|
*/
|
||||||
|
getDashboard: async (): Promise<DashboardResponse> => {
|
||||||
|
const response = await api.get<DashboardResponse>('/dashboard')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default dashboardApi
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Design: Dashboard Widgets
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Dashboard Page │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────┐ │
|
||||||
|
│ │ My Tasks │ │ Due This │ │ Overdue │ │ Done % │ │
|
||||||
|
│ │ 12 │ │ Week: 5 │ │ 2 │ │ 78% │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ └────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌──────────────────────────┐ ┌───────────────────────────┐ │
|
||||||
|
│ │ My Workload │ │ Project Health │ │
|
||||||
|
│ │ ┌────────────────────┐ │ │ ┌───────────────────────┐ │ │
|
||||||
|
│ │ │ 32h / 40h (80%) │ │ │ │ 8 Healthy | 2 At Risk │ │ │
|
||||||
|
│ │ │ ████████░░ │ │ │ │ Avg Score: 76% │ │ │
|
||||||
|
│ │ └────────────────────┘ │ │ └───────────────────────┘ │ │
|
||||||
|
│ └──────────────────────────┘ └───────────────────────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Quick Actions: [Spaces] [Workload] [Health] [Audit*] │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Design
|
||||||
|
|
||||||
|
### GET /api/dashboard
|
||||||
|
|
||||||
|
Single endpoint aggregates all dashboard data to minimize frontend requests.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DashboardResponse {
|
||||||
|
task_stats: {
|
||||||
|
assigned_count: number // Total tasks assigned to user
|
||||||
|
due_this_week: number // Tasks with due_date in current week
|
||||||
|
overdue_count: number // Tasks past due_date, not completed
|
||||||
|
completion_rate: number // Percentage (0-100)
|
||||||
|
}
|
||||||
|
workload: {
|
||||||
|
allocated_hours: number
|
||||||
|
capacity_hours: number
|
||||||
|
load_percentage: number
|
||||||
|
load_level: 'normal' | 'warning' | 'overloaded'
|
||||||
|
}
|
||||||
|
health_summary: {
|
||||||
|
total_projects: number
|
||||||
|
healthy_count: number
|
||||||
|
at_risk_count: number
|
||||||
|
critical_count: number
|
||||||
|
average_health_score: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Sources
|
||||||
|
|
||||||
|
| Widget | Source | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Task Statistics | `pjctrl_tasks` table | Filter by assignee_id = current user |
|
||||||
|
| Workload | Existing `WorkloadService` | Reuse get_user_workload_detail() |
|
||||||
|
| Health Summary | Existing `HealthService` | Reuse get_dashboard() summary |
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
1. **Single API Call**: Frontend makes one request instead of three
|
||||||
|
2. **Query Optimization**: Task stats use COUNT queries, not full rows
|
||||||
|
3. **Caching**: Health summary already cached in Redis (5 min TTL)
|
||||||
|
4. **No N+1**: Avoid loading related entities for counts
|
||||||
|
|
||||||
|
## Component Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── pages/
|
||||||
|
│ └── Dashboard.tsx # Main page (modified)
|
||||||
|
├── components/
|
||||||
|
│ ├── dashboard/
|
||||||
|
│ │ ├── StatisticsCard.tsx # Single stat card
|
||||||
|
│ │ ├── WorkloadWidget.tsx # Workload progress bar
|
||||||
|
│ │ ├── HealthWidget.tsx # Health summary
|
||||||
|
│ │ └── QuickActions.tsx # Navigation links
|
||||||
|
└── services/
|
||||||
|
└── dashboard.ts # API service (new)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trade-offs
|
||||||
|
|
||||||
|
### Decision: Single vs Multiple API Endpoints
|
||||||
|
|
||||||
|
**Chosen**: Single `/api/dashboard` endpoint
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Reduces network round-trips (1 vs 3)
|
||||||
|
- Simpler frontend loading state management
|
||||||
|
- Data is always consistent (same timestamp)
|
||||||
|
|
||||||
|
**Rejected Alternative**: Use existing endpoints directly
|
||||||
|
- Would require 3 parallel requests
|
||||||
|
- More complex error handling
|
||||||
|
- Harder to ensure data consistency
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Proposal: add-dashboard-widgets
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace the placeholder Dashboard page with functional dashboard widgets that provide users with an at-a-glance overview of their work and system status.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The current Dashboard displays only a welcome message and placeholder text ("Features will be added as development progresses"). Users need a centralized view of:
|
||||||
|
- Their assigned tasks and workload
|
||||||
|
- Project health across the organization
|
||||||
|
- Quick access to common actions
|
||||||
|
- System statistics
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
- Dashboard statistics cards (my tasks, overdue, completion rate)
|
||||||
|
- My workload summary widget (reuses existing `/workload/me` API)
|
||||||
|
- Project health summary widget (reuses existing `/projects/health/dashboard` API)
|
||||||
|
- Quick actions section with navigation links
|
||||||
|
- Backend API endpoint for aggregated dashboard data
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
- Customizable widget layout (future enhancement)
|
||||||
|
- Real-time WebSocket updates for dashboard (can be added later)
|
||||||
|
- Dashboard for specific roles (manager vs engineer views)
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Reuses existing APIs:
|
||||||
|
- `GET /workload/me` - User workload summary
|
||||||
|
- `GET /projects/health/dashboard` - Project health overview
|
||||||
|
- `GET /api/projects/{project_id}/tasks` with `assignee_id` filter
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
**Low Risk** - This change primarily adds a new frontend page using existing backend APIs. A new lightweight aggregation endpoint is added but doesn't modify existing data structures.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Dashboard Capability
|
||||||
|
|
||||||
|
Provides users with an at-a-glance overview of their work, project health, and system statistics.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Dashboard Statistics
|
||||||
|
|
||||||
|
The system SHALL display aggregated statistics showing the user's task overview.
|
||||||
|
|
||||||
|
#### Scenario: User views task statistics
|
||||||
|
- Given: User is authenticated
|
||||||
|
- When: User navigates to Dashboard
|
||||||
|
- Then: System displays:
|
||||||
|
- Total tasks assigned to user
|
||||||
|
- Tasks due this week
|
||||||
|
- Overdue tasks count
|
||||||
|
- Completion rate percentage
|
||||||
|
|
||||||
|
### Requirement: My Workload Widget
|
||||||
|
|
||||||
|
The system SHALL display the current user's workload summary for the current week.
|
||||||
|
|
||||||
|
#### Scenario: User views personal workload
|
||||||
|
- Given: User is authenticated
|
||||||
|
- When: User views Dashboard
|
||||||
|
- Then: System displays:
|
||||||
|
- Allocated hours vs capacity hours
|
||||||
|
- Load percentage with visual indicator
|
||||||
|
- Load level status (normal/warning/overloaded)
|
||||||
|
|
||||||
|
### Requirement: Project Health Summary
|
||||||
|
|
||||||
|
The system SHALL display an aggregated project health summary.
|
||||||
|
|
||||||
|
#### Scenario: User views project health overview
|
||||||
|
- Given: User is authenticated
|
||||||
|
- When: User views Dashboard
|
||||||
|
- Then: System displays:
|
||||||
|
- Total projects count
|
||||||
|
- Healthy/At-Risk/Critical breakdown
|
||||||
|
- Average health score
|
||||||
|
- Projects with blockers count
|
||||||
|
|
||||||
|
### Requirement: Quick Actions
|
||||||
|
|
||||||
|
The system SHALL provide quick navigation links to common actions.
|
||||||
|
|
||||||
|
#### Scenario: User accesses quick actions
|
||||||
|
- Given: User is authenticated
|
||||||
|
- When: User views Dashboard
|
||||||
|
- Then: System displays navigation links to:
|
||||||
|
- Spaces page
|
||||||
|
- Workload page
|
||||||
|
- Project Health page
|
||||||
|
- (Admin only) Audit page
|
||||||
|
|
||||||
|
### Requirement: Dashboard API Endpoint
|
||||||
|
|
||||||
|
The backend SHALL provide a single aggregated endpoint for dashboard data.
|
||||||
|
|
||||||
|
#### Scenario: Frontend fetches dashboard data
|
||||||
|
- Given: User is authenticated
|
||||||
|
- When: Frontend requests GET /api/dashboard
|
||||||
|
- Then: Backend returns:
|
||||||
|
- User task statistics
|
||||||
|
- Current week workload summary
|
||||||
|
- Project health summary
|
||||||
|
- And: Response is optimized with single database query where possible
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Implementation Tasks
|
||||||
|
|
||||||
|
## Phase 1: Backend API
|
||||||
|
|
||||||
|
### Task 1.1: Create Dashboard Schema
|
||||||
|
- [ ] Create `backend/app/schemas/dashboard.py` with response models
|
||||||
|
- `TaskStatistics`: assigned_count, due_this_week, overdue_count, completion_rate
|
||||||
|
- `DashboardResponse`: task_stats, workload_summary, health_summary
|
||||||
|
- **Validation**: Schema imports without errors
|
||||||
|
|
||||||
|
### Task 1.2: Create Dashboard Router
|
||||||
|
- [ ] Create `backend/app/api/dashboard/router.py`
|
||||||
|
- [ ] Implement `GET /api/dashboard` endpoint
|
||||||
|
- Query tasks assigned to current user
|
||||||
|
- Reuse workload service for current week summary
|
||||||
|
- Reuse health service for project summary
|
||||||
|
- [ ] Register router in `backend/app/main.py`
|
||||||
|
- **Validation**: `curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/dashboard` returns valid JSON
|
||||||
|
|
||||||
|
## Phase 2: Frontend Implementation
|
||||||
|
|
||||||
|
### Task 2.1: Create Dashboard Service
|
||||||
|
- [ ] Create `frontend/src/services/dashboard.ts`
|
||||||
|
- [ ] Define TypeScript interfaces matching backend schema
|
||||||
|
- [ ] Implement `getDashboard()` API function
|
||||||
|
- **Validation**: TypeScript compiles without errors
|
||||||
|
|
||||||
|
### Task 2.2: Implement Dashboard Components
|
||||||
|
- [ ] Create `StatisticsCard` component for task stats
|
||||||
|
- [ ] Create `WorkloadWidget` component (reuse styles from WorkloadPage)
|
||||||
|
- [ ] Create `HealthSummaryWidget` component
|
||||||
|
- [ ] Create `QuickActions` component with navigation links
|
||||||
|
- **Validation**: Components render without console errors
|
||||||
|
|
||||||
|
### Task 2.3: Integrate Dashboard Page
|
||||||
|
- [ ] Replace placeholder content in `Dashboard.tsx`
|
||||||
|
- [ ] Add loading state with Skeleton components
|
||||||
|
- [ ] Add error handling with retry
|
||||||
|
- [ ] Style components to match existing UI
|
||||||
|
- **Validation**: Dashboard displays data correctly after login
|
||||||
|
|
||||||
|
## Phase 3: Testing & Polish
|
||||||
|
|
||||||
|
### Task 3.1: Add Backend Tests
|
||||||
|
- [ ] Create `backend/tests/test_dashboard.py`
|
||||||
|
- [ ] Test endpoint returns correct structure
|
||||||
|
- [ ] Test data aggregation logic
|
||||||
|
- **Validation**: `pytest tests/test_dashboard.py -v` passes
|
||||||
|
|
||||||
|
### Task 3.2: Visual Polish
|
||||||
|
- [ ] Ensure responsive layout for mobile
|
||||||
|
- [ ] Match color scheme with existing pages
|
||||||
|
- [ ] Add subtle animations for loading states
|
||||||
|
- **Validation**: Manual visual review on different screen sizes
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Tasks 2.x depend on Task 1.2 completion
|
||||||
|
- Task 3.1 can run in parallel with Phase 2
|
||||||
69
openspec/specs/dashboard/spec.md
Normal file
69
openspec/specs/dashboard/spec.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# dashboard Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
TBD - created by archiving change add-dashboard-widgets. Update Purpose after archive.
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Dashboard Statistics
|
||||||
|
|
||||||
|
The system SHALL display aggregated statistics showing the user's task overview.
|
||||||
|
|
||||||
|
#### Scenario: User views task statistics
|
||||||
|
- Given: User is authenticated
|
||||||
|
- When: User navigates to Dashboard
|
||||||
|
- Then: System displays:
|
||||||
|
- Total tasks assigned to user
|
||||||
|
- Tasks due this week
|
||||||
|
- Overdue tasks count
|
||||||
|
- Completion rate percentage
|
||||||
|
|
||||||
|
### Requirement: My Workload Widget
|
||||||
|
|
||||||
|
The system SHALL display the current user's workload summary for the current week.
|
||||||
|
|
||||||
|
#### Scenario: User views personal workload
|
||||||
|
- Given: User is authenticated
|
||||||
|
- When: User views Dashboard
|
||||||
|
- Then: System displays:
|
||||||
|
- Allocated hours vs capacity hours
|
||||||
|
- Load percentage with visual indicator
|
||||||
|
- Load level status (normal/warning/overloaded)
|
||||||
|
|
||||||
|
### Requirement: Project Health Summary
|
||||||
|
|
||||||
|
The system SHALL display an aggregated project health summary.
|
||||||
|
|
||||||
|
#### Scenario: User views project health overview
|
||||||
|
- Given: User is authenticated
|
||||||
|
- When: User views Dashboard
|
||||||
|
- Then: System displays:
|
||||||
|
- Total projects count
|
||||||
|
- Healthy/At-Risk/Critical breakdown
|
||||||
|
- Average health score
|
||||||
|
- Projects with blockers count
|
||||||
|
|
||||||
|
### Requirement: Quick Actions
|
||||||
|
|
||||||
|
The system SHALL provide quick navigation links to common actions.
|
||||||
|
|
||||||
|
#### Scenario: User accesses quick actions
|
||||||
|
- Given: User is authenticated
|
||||||
|
- When: User views Dashboard
|
||||||
|
- Then: System displays navigation links to:
|
||||||
|
- Spaces page
|
||||||
|
- Workload page
|
||||||
|
- Project Health page
|
||||||
|
- (Admin only) Audit page
|
||||||
|
|
||||||
|
### Requirement: Dashboard API Endpoint
|
||||||
|
|
||||||
|
The backend SHALL provide a single aggregated endpoint for dashboard data.
|
||||||
|
|
||||||
|
#### Scenario: Frontend fetches dashboard data
|
||||||
|
- Given: User is authenticated
|
||||||
|
- When: Frontend requests GET /api/dashboard
|
||||||
|
- Then: Backend returns:
|
||||||
|
- User task statistics
|
||||||
|
- Current week workload summary
|
||||||
|
- Project health summary
|
||||||
|
- And: Response is optimized with single database query where possible
|
||||||
|
|
||||||
Reference in New Issue
Block a user