Add Sprint Management feature.

This commit is contained in:
2025-07-04 20:03:30 +02:00
committed by Jens Luedicke
parent 49cc0c94d2
commit 9f4190a29b
8 changed files with 2667 additions and 25 deletions

View File

@@ -17,6 +17,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
ENV LANG=en_US.UTF-8
ENV TZ=Europe/Berlin
# Create www-data user and log directory # Create www-data user and log directory
RUN groupadd -r www-data && useradd -r -g www-data www-data || true RUN groupadd -r www-data && useradd -r -g www-data www-data || true
RUN mkdir -p /var/log/uwsgi && chown -R www-data:www-data /var/log/uwsgi RUN mkdir -p /var/log/uwsgi && chown -R www-data:www-data /var/log/uwsgi

592
app.py
View File

@@ -1,8 +1,8 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent, KanbanBoard, KanbanColumn, KanbanCard, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Sprint, SprintStatus, Announcement, SystemEvent, KanbanBoard, KanbanColumn, KanbanCard, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate
from data_formatting import ( from data_formatting import (
format_duration, prepare_export_data, prepare_team_hours_export_data, format_duration, prepare_export_data, prepare_team_hours_export_data,
format_table_data, format_graph_data, format_team_data format_table_data, format_graph_data, format_team_data, format_burndown_data
) )
from data_export import ( from data_export import (
export_to_csv, export_to_excel, export_team_hours_to_csv, export_team_hours_to_excel, export_to_csv, export_to_excel, export_team_hours_to_csv, export_team_hours_to_excel,
@@ -3331,6 +3331,13 @@ def analytics_data():
# Format data based on view type # Format data based on view type
if view_type == 'graph': if view_type == 'graph':
formatted_data = format_graph_data(data, granularity) formatted_data = format_graph_data(data, granularity)
# For burndown chart, we need task data instead of time entries
chart_type = request.args.get('chart_type', 'timeSeries')
if chart_type == 'burndown':
# Get tasks for burndown chart
tasks = get_filtered_tasks_for_burndown(g.user, mode, start_date, end_date, project_filter)
burndown_data = format_burndown_data(tasks, start_date, end_date)
formatted_data.update(burndown_data)
elif view_type == 'team': elif view_type == 'team':
formatted_data = format_team_data(data, granularity) formatted_data = format_team_data(data, granularity)
else: else:
@@ -3374,6 +3381,48 @@ def get_filtered_analytics_data(user, mode, start_date=None, end_date=None, proj
return query.order_by(TimeEntry.arrival_time.desc()).all() return query.order_by(TimeEntry.arrival_time.desc()).all()
def get_filtered_tasks_for_burndown(user, mode, start_date=None, end_date=None, project_filter=None):
"""Get filtered tasks for burndown chart"""
# Base query - get tasks from user's company
query = Task.query.join(Project).filter(Project.company_id == user.company_id)
# Apply user/team filter
if mode == 'personal':
# For personal mode, get tasks assigned to the user or created by them
query = query.filter(
(Task.assigned_to_id == user.id) |
(Task.created_by_id == user.id)
)
elif mode == 'team' and user.team_id:
# For team mode, get tasks from projects assigned to the team
query = query.filter(Project.team_id == user.team_id)
# Apply project filter
if project_filter:
if project_filter == 'none':
# No project filter for tasks - they must belong to a project
return []
else:
try:
project_id = int(project_filter)
query = query.filter(Task.project_id == project_id)
except ValueError:
pass
# Apply date filters - use task creation date and completion date
if start_date:
query = query.filter(
(Task.created_at >= datetime.combine(start_date, time.min)) |
(Task.completed_date >= start_date)
)
if end_date:
query = query.filter(
Task.created_at <= datetime.combine(end_date, time.max)
)
return query.order_by(Task.created_at.desc()).all()
@app.route('/api/companies/<int:company_id>/teams') @app.route('/api/companies/<int:company_id>/teams')
@system_admin_required @system_admin_required
def api_company_teams(company_id): def api_company_teams(company_id):
@@ -3770,6 +3819,104 @@ def kanban_overview():
projects=projects, projects=projects,
create_board=create_board) create_board=create_board)
# Unified Task Management Route
@app.route('/tasks')
@role_required(Role.TEAM_MEMBER)
@company_required
def unified_task_management():
"""Unified task management interface combining tasks and kanban"""
# Get all projects the user has access to (for filtering and task creation)
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
# Admins and Supervisors can see all company projects
available_projects = Project.query.filter_by(
company_id=g.user.company_id,
is_active=True
).order_by(Project.name).all()
elif g.user.team_id:
# Team members see team projects + unassigned projects
available_projects = Project.query.filter(
Project.company_id == g.user.company_id,
Project.is_active == True,
db.or_(Project.team_id == g.user.team_id, Project.team_id == None)
).order_by(Project.name).all()
# Filter by actual access permissions
available_projects = [p for p in available_projects if p.is_user_allowed(g.user)]
else:
# Unassigned users see only unassigned projects
available_projects = Project.query.filter_by(
company_id=g.user.company_id,
team_id=None,
is_active=True
).order_by(Project.name).all()
available_projects = [p for p in available_projects if p.is_user_allowed(g.user)]
# Get team members for task assignment (company-scoped)
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
# Admins can assign to anyone in the company
team_members = User.query.filter_by(
company_id=g.user.company_id,
is_blocked=False
).order_by(User.username).all()
elif g.user.team_id:
# Team members can assign to team members + supervisors/admins
team_members = User.query.filter(
User.company_id == g.user.company_id,
User.is_blocked == False,
db.or_(
User.team_id == g.user.team_id,
User.role.in_([Role.ADMIN, Role.SUPERVISOR])
)
).order_by(User.username).all()
else:
# Unassigned users can assign to supervisors/admins only
team_members = User.query.filter(
User.company_id == g.user.company_id,
User.is_blocked == False,
User.role.in_([Role.ADMIN, Role.SUPERVISOR])
).order_by(User.username).all()
return render_template('unified_task_management.html',
title='Task Management',
available_projects=available_projects,
team_members=team_members)
# Sprint Management Route
@app.route('/sprints')
@role_required(Role.TEAM_MEMBER)
@company_required
def sprint_management():
"""Sprint management interface"""
# Get all projects the user has access to (for sprint assignment)
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
# Admins and Supervisors can see all company projects
available_projects = Project.query.filter_by(
company_id=g.user.company_id,
is_active=True
).order_by(Project.name).all()
elif g.user.team_id:
# Team members see team projects + unassigned projects
available_projects = Project.query.filter(
Project.company_id == g.user.company_id,
Project.is_active == True,
db.or_(Project.team_id == g.user.team_id, Project.team_id == None)
).order_by(Project.name).all()
# Filter by actual access permissions
available_projects = [p for p in available_projects if p.is_user_allowed(g.user)]
else:
# Unassigned users see only unassigned projects
available_projects = Project.query.filter_by(
company_id=g.user.company_id,
team_id=None,
is_active=True
).order_by(Project.name).all()
available_projects = [p for p in available_projects if p.is_user_allowed(g.user)]
return render_template('sprint_management.html',
title='Sprint Management',
available_projects=available_projects)
# Task API Routes # Task API Routes
@app.route('/api/tasks', methods=['POST']) @app.route('/api/tasks', methods=['POST'])
@role_required(Role.TEAM_MEMBER) @role_required(Role.TEAM_MEMBER)
@@ -3801,8 +3948,8 @@ def create_task():
task = Task( task = Task(
name=name, name=name,
description=data.get('description', ''), description=data.get('description', ''),
status=TaskStatus(data.get('status', 'Not Started')), status=TaskStatus[data.get('status', 'NOT_STARTED')],
priority=TaskPriority(data.get('priority', 'Medium')), priority=TaskPriority[data.get('priority', 'MEDIUM')],
estimated_hours=float(data.get('estimated_hours')) if data.get('estimated_hours') else None, estimated_hours=float(data.get('estimated_hours')) if data.get('estimated_hours') else None,
project_id=project_id, project_id=project_id,
assigned_to_id=int(data.get('assigned_to_id')) if data.get('assigned_to_id') else None, assigned_to_id=int(data.get('assigned_to_id')) if data.get('assigned_to_id') else None,
@@ -3837,8 +3984,8 @@ def get_task(task_id):
'id': task.id, 'id': task.id,
'name': task.name, 'name': task.name,
'description': task.description, 'description': task.description,
'status': task.status.value, 'status': task.status.name,
'priority': task.priority.value, 'priority': task.priority.name,
'estimated_hours': task.estimated_hours, 'estimated_hours': task.estimated_hours,
'assigned_to_id': task.assigned_to_id, 'assigned_to_id': task.assigned_to_id,
'start_date': task.start_date.isoformat() if task.start_date else None, 'start_date': task.start_date.isoformat() if task.start_date else None,
@@ -3871,13 +4018,13 @@ def update_task(task_id):
if 'description' in data: if 'description' in data:
task.description = data['description'] task.description = data['description']
if 'status' in data: if 'status' in data:
task.status = TaskStatus(data['status']) task.status = TaskStatus[data['status']]
if data['status'] == 'Completed': if data['status'] == 'COMPLETED':
task.completed_date = datetime.now().date() task.completed_date = datetime.now().date()
else: else:
task.completed_date = None task.completed_date = None
if 'priority' in data: if 'priority' in data:
task.priority = TaskPriority(data['priority']) task.priority = TaskPriority[data['priority']]
if 'estimated_hours' in data: if 'estimated_hours' in data:
task.estimated_hours = float(data['estimated_hours']) if data['estimated_hours'] else None task.estimated_hours = float(data['estimated_hours']) if data['estimated_hours'] else None
if 'assigned_to_id' in data: if 'assigned_to_id' in data:
@@ -3917,6 +4064,419 @@ def delete_task(task_id):
db.session.rollback() db.session.rollback()
return jsonify({'success': False, 'message': str(e)}) return jsonify({'success': False, 'message': str(e)})
# Unified Task Management APIs
@app.route('/api/tasks/unified')
@role_required(Role.TEAM_MEMBER)
@company_required
def get_unified_tasks():
"""Get all tasks for unified kanban view"""
try:
# Base query for tasks in user's company
query = Task.query.join(Project).filter(Project.company_id == g.user.company_id)
# Apply access restrictions based on user role and team
if g.user.role not in [Role.ADMIN, Role.SUPERVISOR]:
# Regular users can only see tasks from projects they have access to
accessible_project_ids = []
projects = Project.query.filter_by(company_id=g.user.company_id).all()
for project in projects:
if project.is_user_allowed(g.user):
accessible_project_ids.append(project.id)
if accessible_project_ids:
query = query.filter(Task.project_id.in_(accessible_project_ids))
else:
# No accessible projects, return empty list
return jsonify({'success': True, 'tasks': []})
tasks = query.order_by(Task.created_at.desc()).all()
task_list = []
for task in tasks:
# Determine if this is a team task
is_team_task = (
g.user.team_id and
task.project and
task.project.team_id == g.user.team_id
)
task_data = {
'id': task.id,
'name': task.name,
'description': task.description,
'status': task.status.name,
'priority': task.priority.name,
'estimated_hours': task.estimated_hours,
'project_id': task.project_id,
'project_name': task.project.name if task.project else None,
'project_code': task.project.code if task.project else None,
'assigned_to_id': task.assigned_to_id,
'assigned_to_name': task.assigned_to.username if task.assigned_to else None,
'created_by_id': task.created_by_id,
'created_by_name': task.created_by.username if task.created_by else None,
'start_date': task.start_date.isoformat() if task.start_date else None,
'due_date': task.due_date.isoformat() if task.due_date else None,
'completed_date': task.completed_date.isoformat() if task.completed_date else None,
'created_at': task.created_at.isoformat(),
'is_team_task': is_team_task,
'subtask_count': len(task.subtasks) if task.subtasks else 0,
'sprint_id': task.sprint_id,
'sprint_name': task.sprint.name if task.sprint else None,
'is_current_sprint': task.sprint.is_current if task.sprint else False
}
task_list.append(task_data)
return jsonify({'success': True, 'tasks': task_list})
except Exception as e:
logger.error(f"Error in get_unified_tasks: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
@app.route('/api/tasks/<int:task_id>/status', methods=['PUT'])
@role_required(Role.TEAM_MEMBER)
@company_required
def update_task_status(task_id):
"""Update task status (for kanban card movement)"""
try:
task = Task.query.join(Project).filter(
Task.id == task_id,
Project.company_id == g.user.company_id
).first()
if not task or not task.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Task not found or access denied'})
data = request.get_json()
new_status = data.get('status')
if not new_status:
return jsonify({'success': False, 'message': 'Status is required'})
# Validate status value - convert from enum name to enum object
try:
task_status = TaskStatus[new_status]
except KeyError:
return jsonify({'success': False, 'message': 'Invalid status value'})
# Update task status
old_status = task.status
task.status = task_status
# Set completion date if status is COMPLETED
if task_status == TaskStatus.COMPLETED:
task.completed_date = datetime.now().date()
elif old_status == TaskStatus.COMPLETED:
# Clear completion date if moving away from completed
task.completed_date = None
db.session.commit()
return jsonify({
'success': True,
'message': 'Task status updated successfully',
'old_status': old_status.name,
'new_status': task_status.name
})
except Exception as e:
db.session.rollback()
logger.error(f"Error updating task status: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
@app.route('/api/admin/migrate-kanban-cards', methods=['POST'])
@role_required(Role.ADMIN)
@company_required
def migrate_kanban_cards():
"""Admin endpoint to migrate orphaned KanbanCards to Tasks"""
try:
from models import KanbanCard, Task, TaskStatus, TaskPriority, KanbanColumn
def map_column_to_task_status(column_name):
"""Map Kanban column name to Task status"""
column_name = column_name.upper().strip()
if any(keyword in column_name for keyword in ['TODO', 'TO DO', 'BACKLOG', 'PLANNED', 'NOT STARTED']):
return TaskStatus.NOT_STARTED
elif any(keyword in column_name for keyword in ['DOING', 'IN PROGRESS', 'ACTIVE', 'WORKING']):
return TaskStatus.IN_PROGRESS
elif any(keyword in column_name for keyword in ['HOLD', 'BLOCKED', 'WAITING', 'PAUSED']):
return TaskStatus.ON_HOLD
elif any(keyword in column_name for keyword in ['DONE', 'COMPLETE', 'FINISHED', 'CLOSED']):
return TaskStatus.COMPLETED
else:
return TaskStatus.NOT_STARTED
def get_task_priority_from_card(card):
"""Determine task priority from card properties"""
text_to_check = f"{card.title} {card.description or ''}".upper()
if any(keyword in text_to_check for keyword in ['URGENT', 'CRITICAL', 'ASAP', '!!!', 'HIGH PRIORITY']):
return TaskPriority.URGENT
elif any(keyword in text_to_check for keyword in ['HIGH', 'IMPORTANT', '!!']):
return TaskPriority.HIGH
elif any(keyword in text_to_check for keyword in ['LOW', 'MINOR', 'NICE TO HAVE']):
return TaskPriority.LOW
else:
return TaskPriority.MEDIUM
# Find orphaned KanbanCards in user's company
orphaned_cards = db.session.query(KanbanCard).join(KanbanColumn).join(KanbanBoard).filter(
KanbanBoard.company_id == g.user.company_id,
KanbanCard.task_id.is_(None)
).all()
if not orphaned_cards:
return jsonify({'success': True, 'message': 'No orphaned KanbanCards found', 'converted_count': 0})
converted_count = 0
for card in orphaned_cards:
column = KanbanColumn.query.get(card.column_id)
if not column:
continue
task_status = map_column_to_task_status(column.name)
task_priority = get_task_priority_from_card(card)
task = Task(
name=card.title,
description=card.description or '',
status=task_status,
priority=task_priority,
project_id=card.project_id,
assigned_to_id=card.assigned_to_id,
due_date=card.due_date,
completed_date=card.completed_date if task_status == TaskStatus.COMPLETED else None,
created_by_id=card.created_by_id,
created_at=card.created_at,
)
db.session.add(task)
db.session.flush()
card.task_id = task.id
converted_count += 1
db.session.commit()
return jsonify({
'success': True,
'message': f'Successfully converted {converted_count} KanbanCards to Tasks',
'converted_count': converted_count
})
except Exception as e:
db.session.rollback()
logger.error(f"Error in migrate_kanban_cards: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
# Sprint Management APIs
@app.route('/api/sprints')
@role_required(Role.TEAM_MEMBER)
@company_required
def get_sprints():
"""Get all sprints for the user's company"""
try:
# Base query for sprints in user's company
query = Sprint.query.filter(Sprint.company_id == g.user.company_id)
# Apply access restrictions based on user role and team
if g.user.role not in [Role.ADMIN, Role.SUPERVISOR]:
# Regular users can only see sprints they have access to
accessible_sprint_ids = []
sprints = query.all()
for sprint in sprints:
if sprint.can_user_access(g.user):
accessible_sprint_ids.append(sprint.id)
if accessible_sprint_ids:
query = query.filter(Sprint.id.in_(accessible_sprint_ids))
else:
# No accessible sprints, return empty list
return jsonify({'success': True, 'sprints': []})
sprints = query.order_by(Sprint.created_at.desc()).all()
sprint_list = []
for sprint in sprints:
task_summary = sprint.get_task_summary()
sprint_data = {
'id': sprint.id,
'name': sprint.name,
'description': sprint.description,
'status': sprint.status.name,
'company_id': sprint.company_id,
'project_id': sprint.project_id,
'project_name': sprint.project.name if sprint.project else None,
'project_code': sprint.project.code if sprint.project else None,
'start_date': sprint.start_date.isoformat(),
'end_date': sprint.end_date.isoformat(),
'goal': sprint.goal,
'capacity_hours': sprint.capacity_hours,
'created_by_id': sprint.created_by_id,
'created_by_name': sprint.created_by.username if sprint.created_by else None,
'created_at': sprint.created_at.isoformat(),
'is_current': sprint.is_current,
'duration_days': sprint.duration_days,
'days_remaining': sprint.days_remaining,
'progress_percentage': sprint.progress_percentage,
'task_summary': task_summary
}
sprint_list.append(sprint_data)
return jsonify({'success': True, 'sprints': sprint_list})
except Exception as e:
logger.error(f"Error in get_sprints: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
@app.route('/api/sprints', methods=['POST'])
@role_required(Role.TEAM_LEADER) # Team leaders and above can create sprints
@company_required
def create_sprint():
"""Create a new sprint"""
try:
data = request.get_json()
# Validate required fields
name = data.get('name')
start_date = data.get('start_date')
end_date = data.get('end_date')
if not name:
return jsonify({'success': False, 'message': 'Sprint name is required'})
if not start_date:
return jsonify({'success': False, 'message': 'Start date is required'})
if not end_date:
return jsonify({'success': False, 'message': 'End date is required'})
# Parse dates
try:
start_date = datetime.strptime(start_date, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date, '%Y-%m-%d').date()
except ValueError:
return jsonify({'success': False, 'message': 'Invalid date format'})
if start_date >= end_date:
return jsonify({'success': False, 'message': 'End date must be after start date'})
# Verify project access if project is specified
project_id = data.get('project_id')
if project_id:
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
if not project or not project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Project not found or access denied'})
# Create sprint
sprint = Sprint(
name=name,
description=data.get('description', ''),
status=SprintStatus[data.get('status', 'PLANNING')],
company_id=g.user.company_id,
project_id=int(project_id) if project_id else None,
start_date=start_date,
end_date=end_date,
goal=data.get('goal'),
capacity_hours=int(data.get('capacity_hours')) if data.get('capacity_hours') else None,
created_by_id=g.user.id
)
db.session.add(sprint)
db.session.commit()
return jsonify({'success': True, 'message': 'Sprint created successfully'})
except Exception as e:
db.session.rollback()
logger.error(f"Error creating sprint: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
@app.route('/api/sprints/<int:sprint_id>', methods=['PUT'])
@role_required(Role.TEAM_LEADER)
@company_required
def update_sprint(sprint_id):
"""Update an existing sprint"""
try:
sprint = Sprint.query.filter_by(id=sprint_id, company_id=g.user.company_id).first()
if not sprint or not sprint.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Sprint not found or access denied'})
data = request.get_json()
# Update sprint fields
if 'name' in data:
sprint.name = data['name']
if 'description' in data:
sprint.description = data['description']
if 'status' in data:
sprint.status = SprintStatus[data['status']]
if 'goal' in data:
sprint.goal = data['goal']
if 'capacity_hours' in data:
sprint.capacity_hours = int(data['capacity_hours']) if data['capacity_hours'] else None
if 'project_id' in data:
project_id = data['project_id']
if project_id:
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
if not project or not project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Project not found or access denied'})
sprint.project_id = int(project_id)
else:
sprint.project_id = None
# Update dates if provided
if 'start_date' in data:
try:
sprint.start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date()
except ValueError:
return jsonify({'success': False, 'message': 'Invalid start date format'})
if 'end_date' in data:
try:
sprint.end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date()
except ValueError:
return jsonify({'success': False, 'message': 'Invalid end date format'})
# Validate date order
if sprint.start_date >= sprint.end_date:
return jsonify({'success': False, 'message': 'End date must be after start date'})
db.session.commit()
return jsonify({'success': True, 'message': 'Sprint updated successfully'})
except Exception as e:
db.session.rollback()
logger.error(f"Error updating sprint: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
@app.route('/api/sprints/<int:sprint_id>', methods=['DELETE'])
@role_required(Role.TEAM_LEADER)
@company_required
def delete_sprint(sprint_id):
"""Delete a sprint and remove it from all associated tasks"""
try:
sprint = Sprint.query.filter_by(id=sprint_id, company_id=g.user.company_id).first()
if not sprint or not sprint.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Sprint not found or access denied'})
# Remove sprint assignment from all tasks
Task.query.filter_by(sprint_id=sprint_id).update({'sprint_id': None})
# Delete the sprint
db.session.delete(sprint)
db.session.commit()
return jsonify({'success': True, 'message': 'Sprint deleted successfully'})
except Exception as e:
db.session.rollback()
logger.error(f"Error deleting sprint: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
# Subtask API Routes # Subtask API Routes
@app.route('/api/subtasks', methods=['POST']) @app.route('/api/subtasks', methods=['POST'])
@role_required(Role.TEAM_MEMBER) @role_required(Role.TEAM_MEMBER)
@@ -3952,8 +4512,8 @@ def create_subtask():
subtask = SubTask( subtask = SubTask(
name=name, name=name,
description=data.get('description', ''), description=data.get('description', ''),
status=TaskStatus(data.get('status', 'Not Started')), status=TaskStatus[data.get('status', 'NOT_STARTED')],
priority=TaskPriority(data.get('priority', 'Medium')), priority=TaskPriority[data.get('priority', 'MEDIUM')],
estimated_hours=float(data.get('estimated_hours')) if data.get('estimated_hours') else None, estimated_hours=float(data.get('estimated_hours')) if data.get('estimated_hours') else None,
task_id=task_id, task_id=task_id,
assigned_to_id=int(data.get('assigned_to_id')) if data.get('assigned_to_id') else None, assigned_to_id=int(data.get('assigned_to_id')) if data.get('assigned_to_id') else None,
@@ -3988,8 +4548,8 @@ def get_subtask(subtask_id):
'id': subtask.id, 'id': subtask.id,
'name': subtask.name, 'name': subtask.name,
'description': subtask.description, 'description': subtask.description,
'status': subtask.status.value, 'status': subtask.status.name,
'priority': subtask.priority.value, 'priority': subtask.priority.name,
'estimated_hours': subtask.estimated_hours, 'estimated_hours': subtask.estimated_hours,
'assigned_to_id': subtask.assigned_to_id, 'assigned_to_id': subtask.assigned_to_id,
'start_date': subtask.start_date.isoformat() if subtask.start_date else None, 'start_date': subtask.start_date.isoformat() if subtask.start_date else None,
@@ -4022,13 +4582,13 @@ def update_subtask(subtask_id):
if 'description' in data: if 'description' in data:
subtask.description = data['description'] subtask.description = data['description']
if 'status' in data: if 'status' in data:
subtask.status = TaskStatus(data['status']) subtask.status = TaskStatus[data['status']]
if data['status'] == 'Completed': if data['status'] == 'COMPLETED':
subtask.completed_date = datetime.now().date() subtask.completed_date = datetime.now().date()
else: else:
subtask.completed_date = None subtask.completed_date = None
if 'priority' in data: if 'priority' in data:
subtask.priority = TaskPriority(data['priority']) subtask.priority = TaskPriority[data['priority']]
if 'estimated_hours' in data: if 'estimated_hours' in data:
subtask.estimated_hours = float(data['estimated_hours']) if data['estimated_hours'] else None subtask.estimated_hours = float(data['estimated_hours']) if data['estimated_hours'] else None
if 'assigned_to_id' in data: if 'assigned_to_id' in data:

View File

@@ -145,3 +145,64 @@ def format_team_data(entries, granularity='daily'):
}) })
return {'team_data': team_data} return {'team_data': team_data}
def format_burndown_data(tasks, start_date, end_date):
"""Format data for burndown chart visualization."""
from datetime import datetime, timedelta
from models import Task, TaskStatus
if not tasks:
return {'burndown': {'dates': [], 'remaining': [], 'ideal': []}}
# Convert string dates to datetime objects if needed
if isinstance(start_date, str):
start_date = datetime.strptime(start_date, '%Y-%m-%d').date()
if isinstance(end_date, str):
end_date = datetime.strptime(end_date, '%Y-%m-%d').date()
# Generate date range
current_date = start_date
dates = []
while current_date <= end_date:
dates.append(current_date.strftime('%Y-%m-%d'))
current_date += timedelta(days=1)
total_tasks = len(tasks)
if total_tasks == 0:
return {'burndown': {'dates': dates, 'remaining': [0] * len(dates), 'ideal': [0] * len(dates)}}
# Calculate ideal burndown (linear decrease from total to 0)
total_days = len(dates)
ideal_burndown = []
for i in range(total_days):
remaining_ideal = total_tasks - (total_tasks * i / (total_days - 1)) if total_days > 1 else 0
ideal_burndown.append(max(0, round(remaining_ideal, 1)))
# Calculate actual remaining tasks for each date
actual_remaining = []
for date_str in dates:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
# Count tasks not completed by this date
remaining_count = 0
for task in tasks:
# Task is remaining if:
# 1. It's not completed, OR
# 2. It was completed after this date
if task.status != TaskStatus.COMPLETED:
remaining_count += 1
elif task.completed_date and task.completed_date > date_obj:
remaining_count += 1
actual_remaining.append(remaining_count)
return {
'burndown': {
'dates': dates,
'remaining': actual_remaining,
'ideal': ideal_burndown,
'total_tasks': total_tasks,
'tasks_completed': total_tasks - (actual_remaining[-1] if actual_remaining else total_tasks)
}
}

113
models.py
View File

@@ -444,6 +444,9 @@ class Task(db.Model):
# Project association # Project association
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False) project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
# Sprint association (optional)
sprint_id = db.Column(db.Integer, db.ForeignKey('sprint.id'), nullable=True)
# Task assignment # Task assignment
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
@@ -854,6 +857,116 @@ class KanbanCard(db.Model):
"""Get project name for display purposes""" """Get project name for display purposes"""
return self.project.name if self.project else None return self.project.name if self.project else None
# Sprint Management System
class SprintStatus(enum.Enum):
PLANNING = "Planning"
ACTIVE = "Active"
COMPLETED = "Completed"
CANCELLED = "Cancelled"
class Sprint(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# Sprint status
status = db.Column(db.Enum(SprintStatus), nullable=False, default=SprintStatus.PLANNING)
# Company association - sprints are company-scoped
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Optional project association - can be project-specific or company-wide
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
# Sprint timeline
start_date = db.Column(db.Date, nullable=False)
end_date = db.Column(db.Date, nullable=False)
# Sprint goals and metrics
goal = db.Column(db.Text, nullable=True) # Sprint goal description
capacity_hours = db.Column(db.Integer, nullable=True) # Planned capacity in hours
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
company = db.relationship('Company', backref='sprints')
project = db.relationship('Project', backref='sprints')
created_by = db.relationship('User', foreign_keys=[created_by_id])
tasks = db.relationship('Task', backref='sprint', lazy=True)
def __repr__(self):
return f'<Sprint {self.name}>'
@property
def is_current(self):
"""Check if this sprint is currently active"""
from datetime import date
today = date.today()
return (self.status == SprintStatus.ACTIVE and
self.start_date <= today <= self.end_date)
@property
def duration_days(self):
"""Get sprint duration in days"""
return (self.end_date - self.start_date).days + 1
@property
def days_remaining(self):
"""Get remaining days in sprint"""
from datetime import date
today = date.today()
if self.end_date < today:
return 0
elif self.start_date > today:
return self.duration_days
else:
return (self.end_date - today).days + 1
@property
def progress_percentage(self):
"""Calculate sprint progress percentage based on dates"""
from datetime import date
today = date.today()
if today < self.start_date:
return 0
elif today > self.end_date:
return 100
else:
total_days = self.duration_days
elapsed_days = (today - self.start_date).days + 1
return min(100, int((elapsed_days / total_days) * 100))
def get_task_summary(self):
"""Get summary of tasks in this sprint"""
total_tasks = len(self.tasks)
completed_tasks = len([t for t in self.tasks if t.status == TaskStatus.COMPLETED])
in_progress_tasks = len([t for t in self.tasks if t.status == TaskStatus.IN_PROGRESS])
return {
'total': total_tasks,
'completed': completed_tasks,
'in_progress': in_progress_tasks,
'not_started': total_tasks - completed_tasks - in_progress_tasks,
'completion_percentage': int((completed_tasks / total_tasks) * 100) if total_tasks > 0 else 0
}
def can_user_access(self, user):
"""Check if user can access this sprint"""
# Must be in same company
if self.company_id != user.company_id:
return False
# If sprint is project-specific, check project access
if self.project_id:
return self.project.is_user_allowed(user)
# Company-wide sprints are accessible to all company members
return True
# Dashboard Widget System # Dashboard Widget System
class WidgetType(enum.Enum): class WidgetType(enum.Enum):
# Time Tracking Widgets # Time Tracking Widgets

View File

@@ -106,6 +106,7 @@
<select id="chart-type"> <select id="chart-type">
<option value="timeSeries">Time Series</option> <option value="timeSeries">Time Series</option>
<option value="projectDistribution">Project Distribution</option> <option value="projectDistribution">Project Distribution</option>
<option value="burndown">Burndown Chart</option>
</select> </select>
<div class="export-buttons"> <div class="export-buttons">
<button class="btn btn-secondary" onclick="exportChart('png')">Export PNG</button> <button class="btn btn-secondary" onclick="exportChart('png')">Export PNG</button>
@@ -268,7 +269,12 @@ class TimeAnalyticsController {
const chartTypeSelect = document.getElementById('chart-type'); const chartTypeSelect = document.getElementById('chart-type');
if (chartTypeSelect) { if (chartTypeSelect) {
chartTypeSelect.addEventListener('change', () => { chartTypeSelect.addEventListener('change', () => {
// For burndown chart, we need to reload data from the server
if (chartTypeSelect.value === 'burndown') {
this.loadData();
} else {
this.updateChart(); this.updateChart();
}
}); });
} }
@@ -309,6 +315,12 @@ class TimeAnalyticsController {
params.append('project_id', this.state.selectedProject); params.append('project_id', this.state.selectedProject);
} }
// Add chart_type parameter for graph view
if (this.state.activeView === 'graph') {
const chartType = document.getElementById('chart-type')?.value || 'timeSeries';
params.append('chart_type', chartType);
}
const response = await fetch(`/api/analytics/data?${params}`); const response = await fetch(`/api/analytics/data?${params}`);
const data = await response.json(); const data = await response.json();
@@ -396,12 +408,30 @@ class TimeAnalyticsController {
const data = this.state.data; const data = this.state.data;
if (!data) return; if (!data) return;
// Update stats const chartType = document.getElementById('chart-type').value;
// Update stats based on chart type
if (chartType === 'burndown' && data.burndown) {
document.getElementById('total-hours').textContent = data.burndown.total_tasks || '0';
document.getElementById('total-days').textContent = data.burndown.dates?.length || '0';
document.getElementById('avg-hours').textContent = data.burndown.tasks_completed || '0';
// Update stat labels for burndown
document.querySelector('.stat-card:nth-child(1) h4').textContent = 'Total Tasks';
document.querySelector('.stat-card:nth-child(2) h4').textContent = 'Timeline Days';
document.querySelector('.stat-card:nth-child(3) h4').textContent = 'Completed Tasks';
} else {
document.getElementById('total-hours').textContent = data.totalHours?.toFixed(1) || '0'; document.getElementById('total-hours').textContent = data.totalHours?.toFixed(1) || '0';
document.getElementById('total-days').textContent = data.totalDays || '0'; document.getElementById('total-days').textContent = data.totalDays || '0';
document.getElementById('avg-hours').textContent = document.getElementById('avg-hours').textContent =
data.totalDays > 0 ? (data.totalHours / data.totalDays).toFixed(1) : '0'; data.totalDays > 0 ? (data.totalHours / data.totalDays).toFixed(1) : '0';
// Restore original stat labels
document.querySelector('.stat-card:nth-child(1) h4').textContent = 'Total Hours';
document.querySelector('.stat-card:nth-child(2) h4').textContent = 'Total Days';
document.querySelector('.stat-card:nth-child(3) h4').textContent = 'Average Hours/Day';
}
this.updateChart(); this.updateChart();
} }
@@ -483,6 +513,68 @@ class TimeAnalyticsController {
} }
} }
}); });
} else if (chartType === 'burndown') {
this.charts.main = new Chart(ctx, {
type: 'line',
data: {
labels: data.burndown?.dates || [],
datasets: [{
label: 'Remaining Tasks',
data: data.burndown?.remaining || [],
borderColor: '#FF5722',
backgroundColor: 'rgba(255, 87, 34, 0.1)',
fill: true,
tension: 0.1,
pointBackgroundColor: '#FF5722',
pointBorderColor: '#FF5722',
pointRadius: 4
}, {
label: 'Ideal Burndown',
data: data.burndown?.ideal || [],
borderColor: '#4CAF50',
backgroundColor: 'transparent',
borderDash: [5, 5],
fill: false,
tension: 0,
pointRadius: 0
}]
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: 'Project Burndown Chart'
},
legend: {
display: true,
position: 'top'
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Remaining Tasks'
},
ticks: {
stepSize: 1
}
},
x: {
title: {
display: true,
text: 'Date'
}
}
},
interaction: {
intersect: false,
mode: 'index'
}
}
});
} }
} }
@@ -554,7 +646,9 @@ function exportChart(format) {
} else if (format === 'pdf') { } else if (format === 'pdf') {
// Get chart title for PDF // Get chart title for PDF
const chartType = document.getElementById('chart-type').value; const chartType = document.getElementById('chart-type').value;
const title = chartType === 'timeSeries' ? 'Daily Hours Worked' : 'Time Distribution by Project'; const title = chartType === 'timeSeries' ? 'Daily Hours Worked' :
chartType === 'projectDistribution' ? 'Time Distribution by Project' :
'Project Burndown Chart';
// Create PDF using jsPDF // Create PDF using jsPDF
const { jsPDF } = window.jspdf; const { jsPDF } = window.jspdf;

View File

@@ -42,7 +42,8 @@
{% if g.user %} {% if g.user %}
<li><a href="{{ url_for('home') }}" data-tooltip="Home"><i class="nav-icon">🏠</i><span class="nav-text">Home</span></a></li> <li><a href="{{ url_for('home') }}" data-tooltip="Home"><i class="nav-icon">🏠</i><span class="nav-text">Home</span></a></li>
<li><a href="{{ url_for('dashboard') }}" data-tooltip="Dashboard"><i class="nav-icon">📊</i><span class="nav-text">Dashboard</span></a></li> <li><a href="{{ url_for('dashboard') }}" data-tooltip="Dashboard"><i class="nav-icon">📊</i><span class="nav-text">Dashboard</span></a></li>
<li><a href="{{ url_for('kanban_overview') }}" data-tooltip="Kanban Board"><i class="nav-icon">📋</i><span class="nav-text">Kanban Board</span></a></li> <li><a href="{{ url_for('unified_task_management') }}" data-tooltip="Task Management"><i class="nav-icon">📋</i><span class="nav-text">Task Management</span></a></li>
<li><a href="{{ url_for('sprint_management') }}" data-tooltip="Sprint Management"><i class="nav-icon">🏃‍♂️</i><span class="nav-text">Sprints</span></a></li>
<li><a href="{{ url_for('analytics') }}" data-tooltip="Time Analytics"><i class="nav-icon">📊</i><span class="nav-text">Analytics</span></a></li> <li><a href="{{ url_for('analytics') }}" data-tooltip="Time Analytics"><i class="nav-icon">📊</i><span class="nav-text">Analytics</span></a></li>
<!-- Role-based menu items --> <!-- Role-based menu items -->

View File

@@ -0,0 +1,726 @@
{% extends "layout.html" %}
{% block content %}
<div class="sprint-management-container">
<!-- Header Section -->
<div class="sprint-header">
<h1>🏃‍♂️ Sprint Management</h1>
<div class="sprint-controls">
<!-- View Switcher -->
<div class="view-switcher">
<button class="view-btn active" data-view="active">Active Sprints</button>
<button class="view-btn" data-view="all">All Sprints</button>
<button class="view-btn" data-view="planning">Planning</button>
<button class="view-btn" data-view="completed">Completed</button>
</div>
<!-- Actions -->
<div class="sprint-actions">
<button id="add-sprint-btn" class="btn btn-primary">+ New Sprint</button>
<button id="refresh-sprints" class="btn btn-secondary">🔄 Refresh</button>
</div>
</div>
</div>
<!-- Sprint Statistics -->
<div class="sprint-stats">
<div class="stat-card">
<div class="stat-number" id="total-sprints">0</div>
<div class="stat-label">Total Sprints</div>
</div>
<div class="stat-card">
<div class="stat-number" id="active-sprints">0</div>
<div class="stat-label">Active</div>
</div>
<div class="stat-card">
<div class="stat-number" id="completed-sprints">0</div>
<div class="stat-label">Completed</div>
</div>
<div class="stat-card">
<div class="stat-number" id="total-tasks">0</div>
<div class="stat-label">Total Tasks</div>
</div>
</div>
<!-- Sprint Grid -->
<div class="sprint-grid" id="sprint-grid">
<!-- Sprint cards will be populated here -->
</div>
<!-- Loading and Error States -->
<div id="loading-indicator" class="loading-spinner" style="display: none;">
<div class="spinner"></div>
<p>Loading sprints...</p>
</div>
<div id="error-message" class="error-alert" style="display: none;">
<p>Failed to load sprints. Please try again.</p>
</div>
</div>
<!-- Sprint Modal -->
<div id="sprint-modal" class="modal" style="display: none;">
<div class="modal-content large">
<div class="modal-header">
<h2 id="modal-title">Sprint Details</h2>
<span class="close" onclick="closeSprintModal()">&times;</span>
</div>
<div class="modal-body">
<form id="sprint-form">
<input type="hidden" id="sprint-id">
<div class="form-row">
<div class="form-group">
<label for="sprint-name">Sprint Name *</label>
<input type="text" id="sprint-name" required>
</div>
<div class="form-group">
<label for="sprint-status">Status</label>
<select id="sprint-status">
<option value="PLANNING">Planning</option>
<option value="ACTIVE">Active</option>
<option value="COMPLETED">Completed</option>
<option value="CANCELLED">Cancelled</option>
</select>
</div>
</div>
<div class="form-group">
<label for="sprint-description">Description</label>
<textarea id="sprint-description" rows="3"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="sprint-project">Project (Optional)</label>
<select id="sprint-project">
<option value="">Company-wide Sprint</option>
{% for project in available_projects %}
<option value="{{ project.id }}">{{ project.code }} - {{ project.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="sprint-capacity">Capacity (Hours)</label>
<input type="number" id="sprint-capacity" min="0" step="1">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="sprint-start-date">Start Date *</label>
<input type="date" id="sprint-start-date" required>
</div>
<div class="form-group">
<label for="sprint-end-date">End Date *</label>
<input type="date" id="sprint-end-date" required>
</div>
</div>
<div class="form-group">
<label for="sprint-goal">Sprint Goal</label>
<textarea id="sprint-goal" rows="3" placeholder="What is the main objective of this sprint?"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeSprintModal()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveSprint()">Save Sprint</button>
<button type="button" class="btn btn-danger" onclick="deleteSprint()" id="delete-sprint-btn" style="display: none;">Delete Sprint</button>
</div>
</div>
</div>
<!-- Styles -->
<style>
.sprint-management-container {
padding: 1rem;
max-width: 100%;
margin: 0 auto;
}
.sprint-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.sprint-header h1 {
margin: 0;
color: #333;
}
.sprint-controls {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
}
.view-switcher {
display: flex;
background: #f8f9fa;
border-radius: 6px;
padding: 2px;
}
.view-btn {
padding: 0.5rem 1rem;
border: none;
background: transparent;
color: #495057;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
font-weight: 500;
}
.view-btn.active {
background: #007bff;
color: white;
}
.view-btn:hover:not(.active) {
background: #e9ecef;
color: #212529;
}
.sprint-actions {
display: flex;
gap: 0.5rem;
}
.sprint-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
text-align: center;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
color: #007bff;
}
.stat-label {
font-size: 0.9rem;
color: #666;
margin-top: 0.25rem;
}
.sprint-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.sprint-card {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
cursor: pointer;
transition: box-shadow 0.2s, transform 0.1s;
position: relative;
}
.sprint-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.sprint-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.sprint-name {
font-size: 1.25rem;
font-weight: bold;
margin: 0;
color: #333;
}
.sprint-status {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.sprint-status.PLANNING { background: #fff3cd; color: #856404; }
.sprint-status.ACTIVE { background: #d1ecf1; color: #0c5460; }
.sprint-status.COMPLETED { background: #d4edda; color: #155724; }
.sprint-status.CANCELLED { background: #f8d7da; color: #721c24; }
.sprint-dates {
font-size: 0.9rem;
color: #666;
margin-bottom: 1rem;
}
.sprint-progress {
margin-bottom: 1rem;
}
.progress-bar {
width: 100%;
height: 8px;
background: #e9ecef;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #007bff, #0056b3);
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.8rem;
color: #666;
margin-top: 0.25rem;
}
.sprint-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin-bottom: 1rem;
}
.metric {
text-align: center;
padding: 0.5rem;
background: #f8f9fa;
border-radius: 4px;
}
.metric-number {
font-weight: bold;
color: #007bff;
}
.metric-label {
font-size: 0.75rem;
color: #666;
}
.sprint-goal {
font-size: 0.9rem;
color: #666;
font-style: italic;
line-height: 1.4;
}
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #007bff;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-alert {
background: #f8d7da;
color: #721c24;
padding: 1rem;
border-radius: 6px;
text-align: center;
margin: 1rem 0;
}
.modal.large .modal-content {
max-width: 800px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
color: #333;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
@media (max-width: 768px) {
.sprint-header {
flex-direction: column;
align-items: stretch;
}
.sprint-controls {
flex-direction: column;
align-items: stretch;
}
.view-switcher {
justify-content: center;
}
.sprint-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.sprint-metrics {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
<script>
// Sprint Management Controller
class SprintManager {
constructor() {
this.sprints = [];
this.currentView = 'active';
this.currentSprint = null;
}
async init() {
this.setupEventListeners();
await this.loadSprints();
}
setupEventListeners() {
// View switcher
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
this.switchView(e.target.dataset.view);
});
});
// Actions
document.getElementById('add-sprint-btn').addEventListener('click', () => {
this.openSprintModal();
});
document.getElementById('refresh-sprints').addEventListener('click', () => {
this.loadSprints();
});
// Modal close handlers
document.querySelectorAll('.close').forEach(closeBtn => {
closeBtn.addEventListener('click', (e) => {
e.target.closest('.modal').style.display = 'none';
});
});
// Click outside modal to close
window.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) {
e.target.style.display = 'none';
}
});
}
switchView(view) {
// Update button states
document.querySelectorAll('.view-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`[data-view="${view}"]`).classList.add('active');
this.currentView = view;
this.renderSprints();
}
async loadSprints() {
document.getElementById('loading-indicator').style.display = 'flex';
document.getElementById('error-message').style.display = 'none';
try {
const response = await fetch('/api/sprints');
const data = await response.json();
if (data.success) {
this.sprints = data.sprints;
this.renderSprints();
this.updateStatistics();
} else {
throw new Error(data.message || 'Failed to load sprints');
}
} catch (error) {
console.error('Error loading sprints:', error);
document.getElementById('error-message').style.display = 'block';
} finally {
document.getElementById('loading-indicator').style.display = 'none';
}
}
renderSprints() {
const grid = document.getElementById('sprint-grid');
grid.innerHTML = '';
const filteredSprints = this.getFilteredSprints();
if (filteredSprints.length === 0) {
grid.innerHTML = '<div class="empty-state">No sprints found for the selected view.</div>';
return;
}
filteredSprints.forEach(sprint => {
const sprintCard = this.createSprintCard(sprint);
grid.appendChild(sprintCard);
});
}
getFilteredSprints() {
return this.sprints.filter(sprint => {
switch (this.currentView) {
case 'active':
return sprint.status === 'ACTIVE';
case 'planning':
return sprint.status === 'PLANNING';
case 'completed':
return sprint.status === 'COMPLETED';
case 'all':
default:
return true;
}
});
}
createSprintCard(sprint) {
const card = document.createElement('div');
card.className = 'sprint-card';
card.addEventListener('click', () => this.openSprintModal(sprint));
const startDate = new Date(sprint.start_date);
const endDate = new Date(sprint.end_date);
const today = new Date();
// Calculate progress
let progressPercentage = 0;
if (today >= startDate && today <= endDate) {
const totalDays = (endDate - startDate) / (1000 * 60 * 60 * 24);
const elapsedDays = (today - startDate) / (1000 * 60 * 60 * 24);
progressPercentage = Math.min(100, Math.max(0, (elapsedDays / totalDays) * 100));
} else if (today > endDate) {
progressPercentage = 100;
}
card.innerHTML = `
<div class="sprint-card-header">
<h3 class="sprint-name">${sprint.name}</h3>
<span class="sprint-status ${sprint.status}">${sprint.status}</span>
</div>
<div class="sprint-dates">
📅 ${startDate.toLocaleDateString()} - ${endDate.toLocaleDateString()}
${sprint.days_remaining > 0 ? `(${sprint.days_remaining} days left)` : ''}
</div>
<div class="sprint-progress">
<div class="progress-bar">
<div class="progress-fill" style="width: ${progressPercentage}%"></div>
</div>
<div class="progress-text">${Math.round(progressPercentage)}% complete</div>
</div>
<div class="sprint-metrics">
<div class="metric">
<div class="metric-number">${sprint.task_summary.total}</div>
<div class="metric-label">Total</div>
</div>
<div class="metric">
<div class="metric-number">${sprint.task_summary.completed}</div>
<div class="metric-label">Done</div>
</div>
<div class="metric">
<div class="metric-number">${sprint.task_summary.in_progress}</div>
<div class="metric-label">Active</div>
</div>
<div class="metric">
<div class="metric-number">${sprint.task_summary.not_started}</div>
<div class="metric-label">Todo</div>
</div>
</div>
${sprint.goal ? `<div class="sprint-goal">"${sprint.goal}"</div>` : ''}
`;
return card;
}
updateStatistics() {
const totalSprints = this.sprints.length;
const activeSprints = this.sprints.filter(s => s.status === 'ACTIVE').length;
const completedSprints = this.sprints.filter(s => s.status === 'COMPLETED').length;
const totalTasks = this.sprints.reduce((sum, s) => sum + s.task_summary.total, 0);
document.getElementById('total-sprints').textContent = totalSprints;
document.getElementById('active-sprints').textContent = activeSprints;
document.getElementById('completed-sprints').textContent = completedSprints;
document.getElementById('total-tasks').textContent = totalTasks;
}
openSprintModal(sprint = null) {
this.currentSprint = sprint;
const modal = document.getElementById('sprint-modal');
if (sprint) {
document.getElementById('modal-title').textContent = 'Edit Sprint';
document.getElementById('sprint-id').value = sprint.id;
document.getElementById('sprint-name').value = sprint.name;
document.getElementById('sprint-description').value = sprint.description || '';
document.getElementById('sprint-status').value = sprint.status;
document.getElementById('sprint-project').value = sprint.project_id || '';
document.getElementById('sprint-capacity').value = sprint.capacity_hours || '';
document.getElementById('sprint-start-date').value = sprint.start_date;
document.getElementById('sprint-end-date').value = sprint.end_date;
document.getElementById('sprint-goal').value = sprint.goal || '';
document.getElementById('delete-sprint-btn').style.display = 'inline-block';
} else {
document.getElementById('modal-title').textContent = 'Create New Sprint';
document.getElementById('sprint-form').reset();
document.getElementById('sprint-id').value = '';
// Set default dates (next 2 weeks)
const today = new Date();
const twoWeeksLater = new Date(today.getTime() + 14 * 24 * 60 * 60 * 1000);
document.getElementById('sprint-start-date').value = today.toISOString().split('T')[0];
document.getElementById('sprint-end-date').value = twoWeeksLater.toISOString().split('T')[0];
document.getElementById('delete-sprint-btn').style.display = 'none';
}
modal.style.display = 'block';
}
async saveSprint() {
const sprintData = {
name: document.getElementById('sprint-name').value,
description: document.getElementById('sprint-description').value,
status: document.getElementById('sprint-status').value,
project_id: document.getElementById('sprint-project').value || null,
capacity_hours: document.getElementById('sprint-capacity').value || null,
start_date: document.getElementById('sprint-start-date').value,
end_date: document.getElementById('sprint-end-date').value,
goal: document.getElementById('sprint-goal').value || null
};
const sprintId = document.getElementById('sprint-id').value;
const isEdit = sprintId !== '';
try {
const response = await fetch(`/api/sprints${isEdit ? `/${sprintId}` : ''}`, {
method: isEdit ? 'PUT' : 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(sprintData)
});
const data = await response.json();
if (data.success) {
closeSprintModal();
await this.loadSprints();
} else {
throw new Error(data.message || 'Failed to save sprint');
}
} catch (error) {
console.error('Error saving sprint:', error);
alert('Failed to save sprint: ' + error.message);
}
}
async deleteSprint() {
if (!this.currentSprint) return;
if (confirm(`Are you sure you want to delete sprint "${this.currentSprint.name}"? This will also remove the sprint assignment from all tasks.`)) {
try {
const response = await fetch(`/api/sprints/${this.currentSprint.id}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
closeSprintModal();
await this.loadSprints();
} else {
throw new Error(data.message || 'Failed to delete sprint');
}
} catch (error) {
console.error('Error deleting sprint:', error);
alert('Failed to delete sprint: ' + error.message);
}
}
}
}
// Global functions
let sprintManager;
document.addEventListener('DOMContentLoaded', function() {
sprintManager = new SprintManager();
sprintManager.init();
});
function closeSprintModal() {
document.getElementById('sprint-modal').style.display = 'none';
sprintManager.currentSprint = null;
}
function saveSprint() {
sprintManager.saveSprint();
}
function deleteSprint() {
sprintManager.deleteSprint();
}
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff