Add Sprint Management feature.
This commit is contained in:
@@ -17,6 +17,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV TZ=Europe/Berlin
|
||||
|
||||
# Create www-data user and log directory
|
||||
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
|
||||
|
||||
592
app.py
592
app.py
@@ -1,8 +1,8 @@
|
||||
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 (
|
||||
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 (
|
||||
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
|
||||
if view_type == 'graph':
|
||||
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':
|
||||
formatted_data = format_team_data(data, granularity)
|
||||
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()
|
||||
|
||||
|
||||
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')
|
||||
@system_admin_required
|
||||
def api_company_teams(company_id):
|
||||
@@ -3770,6 +3819,104 @@ def kanban_overview():
|
||||
projects=projects,
|
||||
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
|
||||
@app.route('/api/tasks', methods=['POST'])
|
||||
@role_required(Role.TEAM_MEMBER)
|
||||
@@ -3801,8 +3948,8 @@ def create_task():
|
||||
task = Task(
|
||||
name=name,
|
||||
description=data.get('description', ''),
|
||||
status=TaskStatus(data.get('status', 'Not Started')),
|
||||
priority=TaskPriority(data.get('priority', 'Medium')),
|
||||
status=TaskStatus[data.get('status', 'NOT_STARTED')],
|
||||
priority=TaskPriority[data.get('priority', 'MEDIUM')],
|
||||
estimated_hours=float(data.get('estimated_hours')) if data.get('estimated_hours') else None,
|
||||
project_id=project_id,
|
||||
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,
|
||||
'name': task.name,
|
||||
'description': task.description,
|
||||
'status': task.status.value,
|
||||
'priority': task.priority.value,
|
||||
'status': task.status.name,
|
||||
'priority': task.priority.name,
|
||||
'estimated_hours': task.estimated_hours,
|
||||
'assigned_to_id': task.assigned_to_id,
|
||||
'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:
|
||||
task.description = data['description']
|
||||
if 'status' in data:
|
||||
task.status = TaskStatus(data['status'])
|
||||
if data['status'] == 'Completed':
|
||||
task.status = TaskStatus[data['status']]
|
||||
if data['status'] == 'COMPLETED':
|
||||
task.completed_date = datetime.now().date()
|
||||
else:
|
||||
task.completed_date = None
|
||||
if 'priority' in data:
|
||||
task.priority = TaskPriority(data['priority'])
|
||||
task.priority = TaskPriority[data['priority']]
|
||||
if 'estimated_hours' in data:
|
||||
task.estimated_hours = float(data['estimated_hours']) if data['estimated_hours'] else None
|
||||
if 'assigned_to_id' in data:
|
||||
@@ -3917,6 +4064,419 @@ def delete_task(task_id):
|
||||
db.session.rollback()
|
||||
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
|
||||
@app.route('/api/subtasks', methods=['POST'])
|
||||
@role_required(Role.TEAM_MEMBER)
|
||||
@@ -3952,8 +4512,8 @@ def create_subtask():
|
||||
subtask = SubTask(
|
||||
name=name,
|
||||
description=data.get('description', ''),
|
||||
status=TaskStatus(data.get('status', 'Not Started')),
|
||||
priority=TaskPriority(data.get('priority', 'Medium')),
|
||||
status=TaskStatus[data.get('status', 'NOT_STARTED')],
|
||||
priority=TaskPriority[data.get('priority', 'MEDIUM')],
|
||||
estimated_hours=float(data.get('estimated_hours')) if data.get('estimated_hours') else None,
|
||||
task_id=task_id,
|
||||
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,
|
||||
'name': subtask.name,
|
||||
'description': subtask.description,
|
||||
'status': subtask.status.value,
|
||||
'priority': subtask.priority.value,
|
||||
'status': subtask.status.name,
|
||||
'priority': subtask.priority.name,
|
||||
'estimated_hours': subtask.estimated_hours,
|
||||
'assigned_to_id': subtask.assigned_to_id,
|
||||
'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:
|
||||
subtask.description = data['description']
|
||||
if 'status' in data:
|
||||
subtask.status = TaskStatus(data['status'])
|
||||
if data['status'] == 'Completed':
|
||||
subtask.status = TaskStatus[data['status']]
|
||||
if data['status'] == 'COMPLETED':
|
||||
subtask.completed_date = datetime.now().date()
|
||||
else:
|
||||
subtask.completed_date = None
|
||||
if 'priority' in data:
|
||||
subtask.priority = TaskPriority(data['priority'])
|
||||
subtask.priority = TaskPriority[data['priority']]
|
||||
if 'estimated_hours' in data:
|
||||
subtask.estimated_hours = float(data['estimated_hours']) if data['estimated_hours'] else None
|
||||
if 'assigned_to_id' in data:
|
||||
|
||||
@@ -145,3 +145,64 @@ def format_team_data(entries, granularity='daily'):
|
||||
})
|
||||
|
||||
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
113
models.py
@@ -444,6 +444,9 @@ class Task(db.Model):
|
||||
# Project association
|
||||
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
|
||||
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"""
|
||||
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
|
||||
class WidgetType(enum.Enum):
|
||||
# Time Tracking Widgets
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
<select id="chart-type">
|
||||
<option value="timeSeries">Time Series</option>
|
||||
<option value="projectDistribution">Project Distribution</option>
|
||||
<option value="burndown">Burndown Chart</option>
|
||||
</select>
|
||||
<div class="export-buttons">
|
||||
<button class="btn btn-secondary" onclick="exportChart('png')">Export PNG</button>
|
||||
@@ -268,7 +269,12 @@ class TimeAnalyticsController {
|
||||
const chartTypeSelect = document.getElementById('chart-type');
|
||||
if (chartTypeSelect) {
|
||||
chartTypeSelect.addEventListener('change', () => {
|
||||
this.updateChart();
|
||||
// For burndown chart, we need to reload data from the server
|
||||
if (chartTypeSelect.value === 'burndown') {
|
||||
this.loadData();
|
||||
} else {
|
||||
this.updateChart();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -309,6 +315,12 @@ class TimeAnalyticsController {
|
||||
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 data = await response.json();
|
||||
|
||||
@@ -396,11 +408,29 @@ class TimeAnalyticsController {
|
||||
const data = this.state.data;
|
||||
if (!data) return;
|
||||
|
||||
// Update stats
|
||||
document.getElementById('total-hours').textContent = data.totalHours?.toFixed(1) || '0';
|
||||
document.getElementById('total-days').textContent = data.totalDays || '0';
|
||||
document.getElementById('avg-hours').textContent =
|
||||
data.totalDays > 0 ? (data.totalHours / data.totalDays).toFixed(1) : '0';
|
||||
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-days').textContent = data.totalDays || '0';
|
||||
document.getElementById('avg-hours').textContent =
|
||||
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();
|
||||
}
|
||||
@@ -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') {
|
||||
// Get chart title for PDF
|
||||
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
|
||||
const { jsPDF } = window.jspdf;
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
{% 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('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>
|
||||
|
||||
<!-- Role-based menu items -->
|
||||
|
||||
726
templates/sprint_management.html
Normal file
726
templates/sprint_management.html
Normal 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()">×</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 %}
|
||||
1084
templates/unified_task_management.html
Normal file
1084
templates/unified_task_management.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user