Add Kanban Boards for Projects.
This commit is contained in:
668
app.py
668
app.py
@@ -1,5 +1,5 @@
|
|||||||
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
|
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
|
||||||
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
|
||||||
@@ -3392,6 +3392,74 @@ def manage_project_tasks(project_id):
|
|||||||
tasks=tasks,
|
tasks=tasks,
|
||||||
team_members=team_members)
|
team_members=team_members)
|
||||||
|
|
||||||
|
@app.route('/admin/projects/<int:project_id>/kanban')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def project_kanban(project_id):
|
||||||
|
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first_or_404()
|
||||||
|
|
||||||
|
# Check if user has access to this project
|
||||||
|
if not project.is_user_allowed(g.user):
|
||||||
|
flash('You do not have access to this project.', 'error')
|
||||||
|
return redirect(url_for('admin_projects'))
|
||||||
|
|
||||||
|
# Get all Kanban boards for this project
|
||||||
|
boards = KanbanBoard.query.filter_by(project_id=project_id, is_active=True).order_by(KanbanBoard.created_at.desc()).all()
|
||||||
|
|
||||||
|
# Get team members for assignment dropdown
|
||||||
|
if project.team_id:
|
||||||
|
team_members = User.query.filter_by(team_id=project.team_id, company_id=g.user.company_id).all()
|
||||||
|
else:
|
||||||
|
team_members = User.query.filter_by(company_id=g.user.company_id).all()
|
||||||
|
|
||||||
|
# Get tasks for task assignment dropdown
|
||||||
|
tasks = Task.query.filter_by(project_id=project_id).order_by(Task.name).all()
|
||||||
|
|
||||||
|
return render_template('project_kanban.html',
|
||||||
|
title=f'Kanban - {project.name}',
|
||||||
|
project=project,
|
||||||
|
boards=boards,
|
||||||
|
team_members=team_members,
|
||||||
|
tasks=tasks)
|
||||||
|
|
||||||
|
@app.route('/kanban')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def kanban_overview():
|
||||||
|
# Get all projects the user has access to
|
||||||
|
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
|
||||||
|
# Admins and Supervisors can see all company projects
|
||||||
|
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
|
||||||
|
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()
|
||||||
|
else:
|
||||||
|
# Unassigned users see only unassigned projects
|
||||||
|
projects = Project.query.filter_by(
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
team_id=None,
|
||||||
|
is_active=True
|
||||||
|
).order_by(Project.name).all()
|
||||||
|
|
||||||
|
# Get Kanban boards for each project
|
||||||
|
project_boards = {}
|
||||||
|
for project in projects:
|
||||||
|
boards = KanbanBoard.query.filter_by(
|
||||||
|
project_id=project.id,
|
||||||
|
is_active=True
|
||||||
|
).order_by(KanbanBoard.created_at.desc()).all()
|
||||||
|
|
||||||
|
if boards: # Only include projects that have Kanban boards
|
||||||
|
project_boards[project] = boards
|
||||||
|
|
||||||
|
return render_template('kanban_overview.html',
|
||||||
|
title='Kanban Overview',
|
||||||
|
project_boards=project_boards)
|
||||||
|
|
||||||
# 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,6 +3869,604 @@ def delete_category(category_id):
|
|||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return jsonify({'success': False, 'message': str(e)})
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
# Kanban API Routes
|
||||||
|
@app.route('/api/kanban/stats')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def get_kanban_stats():
|
||||||
|
try:
|
||||||
|
# Get all projects the user has access to
|
||||||
|
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
|
||||||
|
projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all()
|
||||||
|
elif g.user.team_id:
|
||||||
|
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)
|
||||||
|
).all()
|
||||||
|
else:
|
||||||
|
projects = Project.query.filter_by(
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
team_id=None,
|
||||||
|
is_active=True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Count boards and cards
|
||||||
|
total_boards = 0
|
||||||
|
total_cards = 0
|
||||||
|
projects_with_boards = 0
|
||||||
|
|
||||||
|
for project in projects:
|
||||||
|
boards = KanbanBoard.query.filter_by(project_id=project.id, is_active=True).all()
|
||||||
|
if boards:
|
||||||
|
projects_with_boards += 1
|
||||||
|
total_boards += len(boards)
|
||||||
|
for board in boards:
|
||||||
|
for column in board.columns:
|
||||||
|
total_cards += len([card for card in column.cards if card.is_active])
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'stats': {
|
||||||
|
'projects_with_boards': projects_with_boards,
|
||||||
|
'total_boards': total_boards,
|
||||||
|
'total_cards': total_cards
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/boards', methods=['GET'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def get_kanban_boards():
|
||||||
|
try:
|
||||||
|
project_id = request.args.get('project_id')
|
||||||
|
|
||||||
|
# Verify project access
|
||||||
|
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'})
|
||||||
|
|
||||||
|
boards = KanbanBoard.query.filter_by(project_id=project_id, is_active=True).all()
|
||||||
|
|
||||||
|
boards_data = []
|
||||||
|
for board in boards:
|
||||||
|
boards_data.append({
|
||||||
|
'id': board.id,
|
||||||
|
'name': board.name,
|
||||||
|
'description': board.description,
|
||||||
|
'is_default': board.is_default,
|
||||||
|
'created_at': board.created_at.isoformat(),
|
||||||
|
'column_count': len(board.columns)
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'boards': boards_data})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/boards', methods=['POST'])
|
||||||
|
@role_required(Role.TEAM_LEADER)
|
||||||
|
@company_required
|
||||||
|
def create_kanban_board():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
project_id = int(data.get('project_id'))
|
||||||
|
|
||||||
|
# Verify project access
|
||||||
|
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'})
|
||||||
|
|
||||||
|
name = data.get('name')
|
||||||
|
if not name:
|
||||||
|
return jsonify({'success': False, 'message': 'Board name is required'})
|
||||||
|
|
||||||
|
# Check if board name already exists in project
|
||||||
|
existing = KanbanBoard.query.filter_by(project_id=project_id, name=name).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({'success': False, 'message': 'Board name already exists in this project'})
|
||||||
|
|
||||||
|
# Create board
|
||||||
|
board = KanbanBoard(
|
||||||
|
name=name,
|
||||||
|
description=data.get('description', ''),
|
||||||
|
project_id=project_id,
|
||||||
|
is_default=data.get('is_default') in ['true', 'on', True],
|
||||||
|
created_by_id=g.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(board)
|
||||||
|
db.session.flush() # Get board ID for columns
|
||||||
|
|
||||||
|
# Create default columns
|
||||||
|
default_columns = [
|
||||||
|
{'name': 'To Do', 'position': 1, 'color': '#6c757d'},
|
||||||
|
{'name': 'In Progress', 'position': 2, 'color': '#007bff'},
|
||||||
|
{'name': 'Done', 'position': 3, 'color': '#28a745'}
|
||||||
|
]
|
||||||
|
|
||||||
|
for col_data in default_columns:
|
||||||
|
column = KanbanColumn(
|
||||||
|
name=col_data['name'],
|
||||||
|
position=col_data['position'],
|
||||||
|
color=col_data['color'],
|
||||||
|
board_id=board.id
|
||||||
|
)
|
||||||
|
db.session.add(column)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Board created successfully', 'board_id': board.id})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/boards/<int:board_id>', methods=['GET'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def get_kanban_board(board_id):
|
||||||
|
try:
|
||||||
|
board = KanbanBoard.query.join(Project).filter(
|
||||||
|
KanbanBoard.id == board_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not board or not board.project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Board not found or access denied'})
|
||||||
|
|
||||||
|
columns_data = []
|
||||||
|
for column in board.columns:
|
||||||
|
if not column.is_active:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cards_data = []
|
||||||
|
for card in column.cards:
|
||||||
|
if not card.is_active:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cards_data.append({
|
||||||
|
'id': card.id,
|
||||||
|
'title': card.title,
|
||||||
|
'description': card.description,
|
||||||
|
'position': card.position,
|
||||||
|
'color': card.color,
|
||||||
|
'assigned_to': {
|
||||||
|
'id': card.assigned_to.id,
|
||||||
|
'username': card.assigned_to.username
|
||||||
|
} if card.assigned_to else None,
|
||||||
|
'task_id': card.task_id,
|
||||||
|
'task_name': card.task.name if card.task else None,
|
||||||
|
'due_date': card.due_date.isoformat() if card.due_date else None,
|
||||||
|
'created_at': card.created_at.isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
columns_data.append({
|
||||||
|
'id': column.id,
|
||||||
|
'name': column.name,
|
||||||
|
'description': column.description,
|
||||||
|
'position': column.position,
|
||||||
|
'color': column.color,
|
||||||
|
'wip_limit': column.wip_limit,
|
||||||
|
'card_count': column.card_count,
|
||||||
|
'is_over_wip_limit': column.is_over_wip_limit,
|
||||||
|
'cards': cards_data
|
||||||
|
})
|
||||||
|
|
||||||
|
board_data = {
|
||||||
|
'id': board.id,
|
||||||
|
'name': board.name,
|
||||||
|
'description': board.description,
|
||||||
|
'project': {
|
||||||
|
'id': board.project.id,
|
||||||
|
'name': board.project.name,
|
||||||
|
'code': board.project.code
|
||||||
|
},
|
||||||
|
'columns': columns_data
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'board': board_data})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/cards', methods=['POST'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def create_kanban_card():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
column_id = data.get('column_id')
|
||||||
|
|
||||||
|
# Verify column access
|
||||||
|
column = KanbanColumn.query.join(KanbanBoard).join(Project).filter(
|
||||||
|
KanbanColumn.id == column_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not column or not column.board.project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Column not found or access denied'})
|
||||||
|
|
||||||
|
title = data.get('title')
|
||||||
|
if not title:
|
||||||
|
return jsonify({'success': False, 'message': 'Card title is required'})
|
||||||
|
|
||||||
|
# Calculate position (add to end of column)
|
||||||
|
max_position = db.session.query(func.max(KanbanCard.position)).filter_by(
|
||||||
|
column_id=column_id, is_active=True
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# Parse due date
|
||||||
|
due_date = None
|
||||||
|
if data.get('due_date'):
|
||||||
|
due_date = datetime.strptime(data.get('due_date'), '%Y-%m-%d').date()
|
||||||
|
|
||||||
|
# Create card
|
||||||
|
card = KanbanCard(
|
||||||
|
title=title,
|
||||||
|
description=data.get('description', ''),
|
||||||
|
position=max_position + 1,
|
||||||
|
color=data.get('color'),
|
||||||
|
column_id=column_id,
|
||||||
|
task_id=int(data.get('task_id')) if data.get('task_id') else None,
|
||||||
|
assigned_to_id=int(data.get('assigned_to_id')) if data.get('assigned_to_id') else None,
|
||||||
|
due_date=due_date,
|
||||||
|
created_by_id=g.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(card)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Card created successfully', 'card_id': card.id})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/cards/<int:card_id>/move', methods=['PUT'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def move_kanban_card(card_id):
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
new_column_id = data.get('column_id')
|
||||||
|
new_position = data.get('position')
|
||||||
|
|
||||||
|
# Verify card access
|
||||||
|
card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).join(Project).filter(
|
||||||
|
KanbanCard.id == card_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not card or not card.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Card not found or access denied'})
|
||||||
|
|
||||||
|
# Verify new column access
|
||||||
|
new_column = KanbanColumn.query.join(KanbanBoard).join(Project).filter(
|
||||||
|
KanbanColumn.id == new_column_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not new_column or not new_column.board.project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Target column not found or access denied'})
|
||||||
|
|
||||||
|
old_column_id = card.column_id
|
||||||
|
old_position = card.position
|
||||||
|
|
||||||
|
# Update positions in old column (if moving to different column)
|
||||||
|
if old_column_id != new_column_id:
|
||||||
|
# Shift cards down in old column
|
||||||
|
cards_to_shift = KanbanCard.query.filter(
|
||||||
|
KanbanCard.column_id == old_column_id,
|
||||||
|
KanbanCard.position > old_position,
|
||||||
|
KanbanCard.is_active == True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for c in cards_to_shift:
|
||||||
|
c.position -= 1
|
||||||
|
|
||||||
|
# Update positions in new column
|
||||||
|
cards_to_shift = KanbanCard.query.filter(
|
||||||
|
KanbanCard.column_id == new_column_id,
|
||||||
|
KanbanCard.position >= new_position,
|
||||||
|
KanbanCard.is_active == True,
|
||||||
|
KanbanCard.id != card_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for c in cards_to_shift:
|
||||||
|
c.position += 1
|
||||||
|
|
||||||
|
# Update card
|
||||||
|
card.column_id = new_column_id
|
||||||
|
card.position = new_position
|
||||||
|
card.updated_at = datetime.now()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Card moved successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/cards/<int:card_id>', methods=['PUT'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def update_kanban_card(card_id):
|
||||||
|
try:
|
||||||
|
card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).join(Project).filter(
|
||||||
|
KanbanCard.id == card_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not card or not card.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Card not found or access denied'})
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# Update card fields
|
||||||
|
if 'title' in data:
|
||||||
|
card.title = data['title']
|
||||||
|
if 'description' in data:
|
||||||
|
card.description = data['description']
|
||||||
|
if 'color' in data:
|
||||||
|
card.color = data['color']
|
||||||
|
if 'assigned_to_id' in data:
|
||||||
|
card.assigned_to_id = int(data['assigned_to_id']) if data['assigned_to_id'] else None
|
||||||
|
if 'task_id' in data:
|
||||||
|
card.task_id = int(data['task_id']) if data['task_id'] else None
|
||||||
|
if 'due_date' in data:
|
||||||
|
card.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date() if data['due_date'] else None
|
||||||
|
|
||||||
|
card.updated_at = datetime.now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Card updated successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/cards/<int:card_id>', methods=['DELETE'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def delete_kanban_card(card_id):
|
||||||
|
try:
|
||||||
|
card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).join(Project).filter(
|
||||||
|
KanbanCard.id == card_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not card or not card.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Card not found or access denied'})
|
||||||
|
|
||||||
|
column_id = card.column_id
|
||||||
|
position = card.position
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
card.is_active = False
|
||||||
|
card.updated_at = datetime.now()
|
||||||
|
|
||||||
|
# Shift remaining cards up
|
||||||
|
cards_to_shift = KanbanCard.query.filter(
|
||||||
|
KanbanCard.column_id == column_id,
|
||||||
|
KanbanCard.position > position,
|
||||||
|
KanbanCard.is_active == True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for c in cards_to_shift:
|
||||||
|
c.position -= 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Card deleted successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
# Kanban Column Management API
|
||||||
|
@app.route('/api/kanban/columns', methods=['POST'])
|
||||||
|
@role_required(Role.TEAM_LEADER)
|
||||||
|
@company_required
|
||||||
|
def create_kanban_column():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
board_id = int(data.get('board_id'))
|
||||||
|
|
||||||
|
# Verify board access
|
||||||
|
board = KanbanBoard.query.join(Project).filter(
|
||||||
|
KanbanBoard.id == board_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not board or not board.project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Board not found or access denied'})
|
||||||
|
|
||||||
|
name = data.get('name')
|
||||||
|
if not name:
|
||||||
|
return jsonify({'success': False, 'message': 'Column name is required'})
|
||||||
|
|
||||||
|
# Check if column name already exists in board
|
||||||
|
existing = KanbanColumn.query.filter_by(board_id=board_id, name=name).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({'success': False, 'message': 'Column name already exists in this board'})
|
||||||
|
|
||||||
|
# Calculate position (add to end)
|
||||||
|
max_position = db.session.query(func.max(KanbanColumn.position)).filter_by(
|
||||||
|
board_id=board_id, is_active=True
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# Create column
|
||||||
|
column = KanbanColumn(
|
||||||
|
name=name,
|
||||||
|
description=data.get('description', ''),
|
||||||
|
position=max_position + 1,
|
||||||
|
color=data.get('color', '#6c757d'),
|
||||||
|
wip_limit=int(data.get('wip_limit')) if data.get('wip_limit') else None,
|
||||||
|
board_id=board_id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(column)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Column created successfully', 'column_id': column.id})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/columns/<int:column_id>', methods=['PUT'])
|
||||||
|
@role_required(Role.TEAM_LEADER)
|
||||||
|
@company_required
|
||||||
|
def update_kanban_column(column_id):
|
||||||
|
try:
|
||||||
|
column = KanbanColumn.query.join(KanbanBoard).join(Project).filter(
|
||||||
|
KanbanColumn.id == column_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not column or not column.board.project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Column not found or access denied'})
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# Check for name conflicts (excluding current column)
|
||||||
|
if 'name' in data:
|
||||||
|
existing = KanbanColumn.query.filter(
|
||||||
|
KanbanColumn.board_id == column.board_id,
|
||||||
|
KanbanColumn.name == data['name'],
|
||||||
|
KanbanColumn.id != column_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({'success': False, 'message': 'Column name already exists in this board'})
|
||||||
|
|
||||||
|
# Update column fields
|
||||||
|
if 'name' in data:
|
||||||
|
column.name = data['name']
|
||||||
|
if 'description' in data:
|
||||||
|
column.description = data['description']
|
||||||
|
if 'color' in data:
|
||||||
|
column.color = data['color']
|
||||||
|
if 'wip_limit' in data:
|
||||||
|
column.wip_limit = int(data['wip_limit']) if data['wip_limit'] else None
|
||||||
|
|
||||||
|
column.updated_at = datetime.now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Column updated successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/columns/<int:column_id>/move', methods=['PUT'])
|
||||||
|
@role_required(Role.TEAM_LEADER)
|
||||||
|
@company_required
|
||||||
|
def move_kanban_column(column_id):
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
new_position = int(data.get('position'))
|
||||||
|
|
||||||
|
# Verify column access
|
||||||
|
column = KanbanColumn.query.join(KanbanBoard).join(Project).filter(
|
||||||
|
KanbanColumn.id == column_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not column or not column.board.project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Column not found or access denied'})
|
||||||
|
|
||||||
|
old_position = column.position
|
||||||
|
board_id = column.board_id
|
||||||
|
|
||||||
|
if old_position == new_position:
|
||||||
|
return jsonify({'success': True, 'message': 'Column position unchanged'})
|
||||||
|
|
||||||
|
# Update positions of other columns
|
||||||
|
if old_position < new_position:
|
||||||
|
# Moving right: shift columns left
|
||||||
|
columns_to_shift = KanbanColumn.query.filter(
|
||||||
|
KanbanColumn.board_id == board_id,
|
||||||
|
KanbanColumn.position > old_position,
|
||||||
|
KanbanColumn.position <= new_position,
|
||||||
|
KanbanColumn.is_active == True,
|
||||||
|
KanbanColumn.id != column_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for c in columns_to_shift:
|
||||||
|
c.position -= 1
|
||||||
|
else:
|
||||||
|
# Moving left: shift columns right
|
||||||
|
columns_to_shift = KanbanColumn.query.filter(
|
||||||
|
KanbanColumn.board_id == board_id,
|
||||||
|
KanbanColumn.position >= new_position,
|
||||||
|
KanbanColumn.position < old_position,
|
||||||
|
KanbanColumn.is_active == True,
|
||||||
|
KanbanColumn.id != column_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for c in columns_to_shift:
|
||||||
|
c.position += 1
|
||||||
|
|
||||||
|
# Update the moved column
|
||||||
|
column.position = new_position
|
||||||
|
column.updated_at = datetime.now()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Column moved successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/columns/<int:column_id>', methods=['DELETE'])
|
||||||
|
@role_required(Role.TEAM_LEADER)
|
||||||
|
@company_required
|
||||||
|
def delete_kanban_column(column_id):
|
||||||
|
try:
|
||||||
|
column = KanbanColumn.query.join(KanbanBoard).join(Project).filter(
|
||||||
|
KanbanColumn.id == column_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not column or not column.board.project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Column not found or access denied'})
|
||||||
|
|
||||||
|
# Check if column has active cards
|
||||||
|
active_cards = KanbanCard.query.filter_by(column_id=column_id, is_active=True).count()
|
||||||
|
if active_cards > 0:
|
||||||
|
return jsonify({'success': False, 'message': f'Cannot delete column with {active_cards} active cards. Move or delete cards first.'})
|
||||||
|
|
||||||
|
board_id = column.board_id
|
||||||
|
position = column.position
|
||||||
|
|
||||||
|
# Soft delete the column
|
||||||
|
column.is_active = False
|
||||||
|
column.updated_at = datetime.now()
|
||||||
|
|
||||||
|
# Shift remaining columns left
|
||||||
|
columns_to_shift = KanbanColumn.query.filter(
|
||||||
|
KanbanColumn.board_id == board_id,
|
||||||
|
KanbanColumn.position > position,
|
||||||
|
KanbanColumn.is_active == True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for c in columns_to_shift:
|
||||||
|
c.position -= 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Column deleted successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
port = int(os.environ.get('PORT', 5000))
|
port = int(os.environ.get('PORT', 5000))
|
||||||
app.run(debug=False, host='0.0.0.0', port=port)
|
app.run(debug=False, host='0.0.0.0', port=port)
|
||||||
111
migrate_db.py
111
migrate_db.py
@@ -15,7 +15,8 @@ try:
|
|||||||
from app import app, db
|
from app import app, db
|
||||||
from models import (User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project,
|
from models import (User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project,
|
||||||
Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType,
|
Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType,
|
||||||
ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent)
|
ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent, KanbanBoard,
|
||||||
|
KanbanColumn, KanbanCard)
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
FLASK_AVAILABLE = True
|
FLASK_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -72,6 +73,7 @@ def run_all_migrations(db_path=None):
|
|||||||
migrate_work_config_data(db_path)
|
migrate_work_config_data(db_path)
|
||||||
migrate_task_system(db_path)
|
migrate_task_system(db_path)
|
||||||
migrate_system_events(db_path)
|
migrate_system_events(db_path)
|
||||||
|
migrate_kanban_system(db_path)
|
||||||
|
|
||||||
if FLASK_AVAILABLE:
|
if FLASK_AVAILABLE:
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@@ -882,6 +884,108 @@ def create_all_tables(cursor):
|
|||||||
print("All tables created")
|
print("All tables created")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_kanban_system(db_file=None):
|
||||||
|
"""Migrate to add Kanban board system."""
|
||||||
|
db_path = get_db_path(db_file)
|
||||||
|
|
||||||
|
print(f"Migrating Kanban system in {db_path}...")
|
||||||
|
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
print(f"Database file {db_path} does not exist. Run basic migration first.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if kanban_board table already exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='kanban_board'")
|
||||||
|
if cursor.fetchone():
|
||||||
|
print("Kanban tables already exist. Skipping migration.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
print("Creating Kanban board tables...")
|
||||||
|
|
||||||
|
# Create kanban_board table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE kanban_board (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
project_id INTEGER NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
is_default BOOLEAN DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by_id INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES project (id),
|
||||||
|
FOREIGN KEY (created_by_id) REFERENCES user (id),
|
||||||
|
UNIQUE(project_id, name)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create kanban_column table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE kanban_column (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
color VARCHAR(7) DEFAULT '#6c757d',
|
||||||
|
wip_limit INTEGER,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
board_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (board_id) REFERENCES kanban_board (id),
|
||||||
|
UNIQUE(board_id, name)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create kanban_card table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE kanban_card (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
color VARCHAR(7),
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
column_id INTEGER NOT NULL,
|
||||||
|
task_id INTEGER,
|
||||||
|
assigned_to_id INTEGER,
|
||||||
|
due_date DATE,
|
||||||
|
completed_date DATE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by_id INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (column_id) REFERENCES kanban_column (id),
|
||||||
|
FOREIGN KEY (task_id) REFERENCES task (id),
|
||||||
|
FOREIGN KEY (assigned_to_id) REFERENCES user (id),
|
||||||
|
FOREIGN KEY (created_by_id) REFERENCES user (id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create indexes for better performance
|
||||||
|
cursor.execute("CREATE INDEX idx_kanban_board_project ON kanban_board(project_id)")
|
||||||
|
cursor.execute("CREATE INDEX idx_kanban_column_board ON kanban_column(board_id)")
|
||||||
|
cursor.execute("CREATE INDEX idx_kanban_card_column ON kanban_card(column_id)")
|
||||||
|
cursor.execute("CREATE INDEX idx_kanban_card_task ON kanban_card(task_id)")
|
||||||
|
cursor.execute("CREATE INDEX idx_kanban_card_assigned ON kanban_card(assigned_to_id)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("Kanban system migration completed successfully!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during Kanban system migration: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main function with command line interface."""
|
"""Main function with command line interface."""
|
||||||
parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool')
|
parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool')
|
||||||
@@ -898,6 +1002,8 @@ def main():
|
|||||||
help='Run only basic table migrations')
|
help='Run only basic table migrations')
|
||||||
parser.add_argument('--system-events', '-s', action='store_true',
|
parser.add_argument('--system-events', '-s', action='store_true',
|
||||||
help='Run only system events migration')
|
help='Run only system events migration')
|
||||||
|
parser.add_argument('--kanban', '-k', action='store_true',
|
||||||
|
help='Run only Kanban system migration')
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -930,6 +1036,9 @@ def main():
|
|||||||
elif args.system_events:
|
elif args.system_events:
|
||||||
migrate_system_events(db_path)
|
migrate_system_events(db_path)
|
||||||
|
|
||||||
|
elif args.kanban:
|
||||||
|
migrate_kanban_system(db_path)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Default: run all migrations
|
# Default: run all migrations
|
||||||
run_all_migrations(db_path)
|
run_all_migrations(db_path)
|
||||||
|
|||||||
109
models.py
109
models.py
@@ -722,3 +722,112 @@ class SystemEvent(db.Model):
|
|||||||
'last_error': last_error,
|
'last_error': last_error,
|
||||||
'health_status': 'healthy' if recent_errors == 0 else 'issues' if recent_errors < 5 else 'critical'
|
'health_status': 'healthy' if recent_errors == 0 else 'issues' if recent_errors < 5 else 'critical'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Kanban Board models
|
||||||
|
class KanbanBoard(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Project association
|
||||||
|
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
|
||||||
|
|
||||||
|
# Board settings
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
is_default = db.Column(db.Boolean, default=False) # Default board for project
|
||||||
|
|
||||||
|
# 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
|
||||||
|
project = db.relationship('Project', backref='kanban_boards')
|
||||||
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
|
columns = db.relationship('KanbanColumn', backref='board', lazy=True, cascade='all, delete-orphan', order_by='KanbanColumn.position')
|
||||||
|
|
||||||
|
# Unique constraint per project
|
||||||
|
__table_args__ = (db.UniqueConstraint('project_id', 'name', name='uq_kanban_board_name_per_project'),)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<KanbanBoard {self.name}>'
|
||||||
|
|
||||||
|
class KanbanColumn(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Column settings
|
||||||
|
position = db.Column(db.Integer, nullable=False) # Order in board
|
||||||
|
color = db.Column(db.String(7), default='#6c757d') # Hex color
|
||||||
|
wip_limit = db.Column(db.Integer, nullable=True) # Work in progress limit
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
|
||||||
|
# Board association
|
||||||
|
board_id = db.Column(db.Integer, db.ForeignKey('kanban_board.id'), nullable=False)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
cards = db.relationship('KanbanCard', backref='column', lazy=True, cascade='all, delete-orphan', order_by='KanbanCard.position')
|
||||||
|
|
||||||
|
# Unique constraint per board
|
||||||
|
__table_args__ = (db.UniqueConstraint('board_id', 'name', name='uq_kanban_column_name_per_board'),)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<KanbanColumn {self.name}>'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def card_count(self):
|
||||||
|
"""Get number of cards in this column"""
|
||||||
|
return len([card for card in self.cards if card.is_active])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_over_wip_limit(self):
|
||||||
|
"""Check if column is over WIP limit"""
|
||||||
|
if not self.wip_limit:
|
||||||
|
return False
|
||||||
|
return self.card_count > self.wip_limit
|
||||||
|
|
||||||
|
class KanbanCard(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(200), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Card settings
|
||||||
|
position = db.Column(db.Integer, nullable=False) # Order in column
|
||||||
|
color = db.Column(db.String(7), nullable=True) # Optional custom color
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
|
||||||
|
# Column association
|
||||||
|
column_id = db.Column(db.Integer, db.ForeignKey('kanban_column.id'), nullable=False)
|
||||||
|
|
||||||
|
# Optional task association
|
||||||
|
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True)
|
||||||
|
|
||||||
|
# Card assignment
|
||||||
|
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
|
|
||||||
|
# Card dates
|
||||||
|
due_date = db.Column(db.Date, nullable=True)
|
||||||
|
completed_date = db.Column(db.Date, nullable=True)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
task = db.relationship('Task', backref='kanban_cards')
|
||||||
|
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_kanban_cards')
|
||||||
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<KanbanCard {self.title}>'
|
||||||
|
|
||||||
|
def can_user_access(self, user):
|
||||||
|
"""Check if a user can access this card"""
|
||||||
|
# Check board's project permissions
|
||||||
|
return self.column.board.project.is_user_allowed(user)
|
||||||
@@ -93,6 +93,7 @@
|
|||||||
<td class="actions">
|
<td class="actions">
|
||||||
<a href="{{ url_for('edit_project', project_id=project.id) }}" class="btn btn-sm btn-primary">Edit</a>
|
<a href="{{ url_for('edit_project', project_id=project.id) }}" class="btn btn-sm btn-primary">Edit</a>
|
||||||
<a href="{{ url_for('manage_project_tasks', project_id=project.id) }}" class="btn btn-sm btn-info">Tasks</a>
|
<a href="{{ url_for('manage_project_tasks', project_id=project.id) }}" class="btn btn-sm btn-info">Tasks</a>
|
||||||
|
<a href="{{ url_for('project_kanban', project_id=project.id) }}" class="btn btn-sm btn-success">Kanban</a>
|
||||||
{% if g.user.role == Role.ADMIN and project.time_entries|length == 0 %}
|
{% if g.user.role == Role.ADMIN and project.time_entries|length == 0 %}
|
||||||
<form method="POST" action="{{ url_for('delete_project', project_id=project.id) }}" style="display: inline;"
|
<form method="POST" action="{{ url_for('delete_project', project_id=project.id) }}" style="display: inline;"
|
||||||
onsubmit="return confirm('Are you sure you want to delete this project?')">
|
onsubmit="return confirm('Are you sure you want to delete this project?')">
|
||||||
|
|||||||
517
templates/kanban_overview.html
Normal file
517
templates/kanban_overview.html
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="kanban-overview-container">
|
||||||
|
<div class="overview-header">
|
||||||
|
<h2>Kanban Board Overview</h2>
|
||||||
|
<p class="overview-description">Manage your tasks visually across all projects</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if project_boards %}
|
||||||
|
<div class="projects-grid">
|
||||||
|
{% for project, boards in project_boards.items() %}
|
||||||
|
<div class="project-card">
|
||||||
|
<div class="project-header">
|
||||||
|
<div class="project-info">
|
||||||
|
<h3 class="project-name">
|
||||||
|
<span class="project-code">{{ project.code }}</span>
|
||||||
|
{{ project.name }}
|
||||||
|
</h3>
|
||||||
|
{% if project.category %}
|
||||||
|
<span class="category-badge" style="background-color: {{ project.category.color }}20; color: {{ project.category.color }};">
|
||||||
|
{{ project.category.icon or '📁' }} {{ project.category.name }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if project.description %}
|
||||||
|
<p class="project-description">{{ project.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="project-actions">
|
||||||
|
<a href="{{ url_for('project_kanban', project_id=project.id) }}" class="btn btn-primary">Open Kanban</a>
|
||||||
|
<a href="{{ url_for('manage_project_tasks', project_id=project.id) }}" class="btn btn-secondary">Task List</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="boards-section">
|
||||||
|
<h4>Kanban Boards ({{ boards|length }})</h4>
|
||||||
|
<div class="boards-list">
|
||||||
|
{% for board in boards %}
|
||||||
|
<div class="board-item" onclick="openBoard({{ project.id }}, {{ board.id }})">
|
||||||
|
<div class="board-info">
|
||||||
|
<div class="board-name">
|
||||||
|
{{ board.name }}
|
||||||
|
{% if board.is_default %}
|
||||||
|
<span class="default-badge">Default</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if board.description %}
|
||||||
|
<div class="board-description">{{ board.description }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="board-stats">
|
||||||
|
<span class="column-count">{{ board.columns|length }} columns</span>
|
||||||
|
<span class="card-count">
|
||||||
|
{% set board_cards = 0 %}
|
||||||
|
{% for column in board.columns %}
|
||||||
|
{% set board_cards = board_cards + column.cards|selectattr('is_active')|list|length %}
|
||||||
|
{% endfor %}
|
||||||
|
{{ board_cards }} cards
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Board Preview -->
|
||||||
|
<div class="board-preview">
|
||||||
|
{% set default_board = boards|selectattr('is_default')|first %}
|
||||||
|
{% if not default_board %}
|
||||||
|
{% set default_board = boards|first %}
|
||||||
|
{% endif %}
|
||||||
|
{% if default_board and default_board.columns %}
|
||||||
|
<h5>{{ default_board.name }} Preview</h5>
|
||||||
|
<div class="preview-columns">
|
||||||
|
{% for column in default_board.columns %}
|
||||||
|
{% if loop.index <= 4 %}
|
||||||
|
<div class="preview-column" style="border-top: 3px solid {{ column.color }};">
|
||||||
|
<div class="preview-column-header">
|
||||||
|
<span class="preview-column-name">{{ column.name }}</span>
|
||||||
|
<span class="preview-card-count">{{ column.cards|selectattr('is_active')|list|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-cards">
|
||||||
|
{% set active_cards = column.cards|selectattr('is_active')|list %}
|
||||||
|
{% for card in active_cards %}
|
||||||
|
{% if loop.index <= 3 %}
|
||||||
|
<div class="preview-card" {% if card.color %}style="background-color: {{ card.color }};"{% endif %}>
|
||||||
|
<div class="preview-card-title">
|
||||||
|
{% if card.title|length > 30 %}
|
||||||
|
{{ card.title|truncate(30, True) }}
|
||||||
|
{% else %}
|
||||||
|
{{ card.title }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if card.assigned_to %}
|
||||||
|
<div class="preview-card-assignee">{{ card.assigned_to.username }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if active_cards|length > 3 %}
|
||||||
|
<div class="preview-more">+{{ active_cards|length - 3 }} more</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if default_board.columns|length > 4 %}
|
||||||
|
<div class="preview-more-columns">
|
||||||
|
+{{ default_board.columns|length - 4 }} more columns
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="quick-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>{{ project_boards.keys()|list|length }}</h3>
|
||||||
|
<p>Projects with Kanban</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
{% set total_boards = 0 %}
|
||||||
|
{% for project, boards in project_boards.items() %}
|
||||||
|
{% set total_boards = total_boards + boards|length %}
|
||||||
|
{% endfor %}
|
||||||
|
<h3>{{ total_boards }}</h3>
|
||||||
|
<p>Total Boards</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
{% set total_cards = 0 %}
|
||||||
|
{% for project, boards in project_boards.items() %}
|
||||||
|
{% for board in boards %}
|
||||||
|
{% for column in board.columns %}
|
||||||
|
{% set total_cards = total_cards + column.cards|selectattr('is_active')|list|length %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
<h3>{{ total_cards }}</h3>
|
||||||
|
<p>Total Cards</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- No Kanban Boards -->
|
||||||
|
<div class="no-kanban">
|
||||||
|
<div class="no-kanban-content">
|
||||||
|
<div class="no-kanban-icon">📋</div>
|
||||||
|
<h3>No Kanban Boards Yet</h3>
|
||||||
|
<p>Start organizing your projects with visual Kanban boards.</p>
|
||||||
|
<div class="getting-started">
|
||||||
|
<h4>Getting Started:</h4>
|
||||||
|
<ol>
|
||||||
|
<li>Go to a project from <a href="{{ url_for('admin_projects') }}">Project Management</a></li>
|
||||||
|
<li>Click the <strong>"Kanban"</strong> button</li>
|
||||||
|
<li>Create your first board and start organizing tasks</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %}
|
||||||
|
<a href="{{ url_for('admin_projects') }}" class="btn btn-primary">Go to Projects</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.kanban-overview-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-header h2 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-description {
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-name {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-code {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-description {
|
||||||
|
color: #666;
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boards-section {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boards-section h4 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boards-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-item {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-item:hover {
|
||||||
|
background: #e7f3ff;
|
||||||
|
border-color: #007bff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-badge {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-description {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-preview {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-preview h5 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-column {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-column-header {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-column-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card-count {
|
||||||
|
background: #e9ecef;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-cards {
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card-title {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card-assignee {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-more {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-more-columns {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-kanban {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-kanban-content {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-kanban-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-kanban h3 {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-kanban p {
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.getting-started {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.getting-started h4 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.getting-started ol {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.getting-started li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.project-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-columns {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openBoard(projectId, boardId) {
|
||||||
|
window.location.href = `/admin/projects/${projectId}/kanban?board=${boardId}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
{% 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('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('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 -->
|
||||||
|
|||||||
1377
templates/project_kanban.html
Normal file
1377
templates/project_kanban.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user