Add Kanban feature.

ADD: Kanban Boards.
FIX: Fix user deletion.
This commit is contained in:
2025-07-04 17:10:33 +02:00
committed by Jens Luedicke
parent 336d998a8a
commit e4cc154f92
5 changed files with 1305 additions and 404 deletions

546
app.py
View File

@@ -1039,12 +1039,249 @@ def delete_user(user_id):
return redirect(url_for('admin_users')) return redirect(url_for('admin_users'))
username = user.username username = user.username
try:
# Handle dependent records before deleting user
# Find an alternative admin/supervisor to transfer ownership to
alternative_admin = User.query.filter(
User.company_id == g.user.company_id,
User.role.in_([Role.ADMIN, Role.SUPERVISOR]),
User.id != user_id
).first()
if alternative_admin:
# Transfer ownership of projects to alternative admin
Project.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
# Transfer ownership of tasks to alternative admin
Task.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
# Transfer ownership of subtasks to alternative admin
SubTask.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
# Transfer ownership of project categories to alternative admin
ProjectCategory.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
# Transfer ownership of kanban boards to alternative admin
KanbanBoard.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
# Transfer ownership of kanban cards to alternative admin
KanbanCard.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
else:
# No alternative admin found - redirect to company deletion confirmation
flash('No other administrator or supervisor found. Company deletion required.', 'warning')
return redirect(url_for('confirm_company_deletion', user_id=user_id))
# Delete user-specific records that can be safely removed
TimeEntry.query.filter_by(user_id=user_id).delete()
WorkConfig.query.filter_by(user_id=user_id).delete()
UserPreferences.query.filter_by(user_id=user_id).delete()
# Delete user dashboards (cascades to widgets)
UserDashboard.query.filter_by(user_id=user_id).delete()
# Clear task and subtask assignments
Task.query.filter_by(assigned_to_id=user_id).update({'assigned_to_id': None})
SubTask.query.filter_by(assigned_to_id=user_id).update({'assigned_to_id': None})
KanbanCard.query.filter_by(assigned_to_id=user_id).update({'assigned_to_id': None})
# Now safe to delete the user
db.session.delete(user) db.session.delete(user)
db.session.commit() db.session.commit()
flash(f'User {username} deleted successfully', 'success') flash(f'User {username} deleted successfully. Projects and tasks transferred to {alternative_admin.username}', 'success')
except Exception as e:
db.session.rollback()
logger.error(f"Error deleting user {user_id}: {str(e)}")
flash(f'Error deleting user: {str(e)}', 'error')
return redirect(url_for('admin_users')) return redirect(url_for('admin_users'))
@app.route('/confirm-company-deletion/<int:user_id>', methods=['GET', 'POST'])
@login_required
def confirm_company_deletion(user_id):
"""Show confirmation page for company deletion when no alternative admin exists"""
# Only allow admin or system admin access
if g.user.role not in [Role.ADMIN, Role.SYSTEM_ADMIN]:
flash('Access denied: Admin privileges required', 'error')
return redirect(url_for('index'))
user = User.query.get_or_404(user_id)
# For admin users, ensure they're in the same company
if g.user.role == Role.ADMIN and user.company_id != g.user.company_id:
flash('Access denied: You can only delete users in your company', 'error')
return redirect(url_for('admin_users'))
# Prevent deleting yourself
if user.id == g.user.id:
flash('You cannot delete your own account', 'error')
return redirect(url_for('admin_users') if g.user.role != Role.SYSTEM_ADMIN else url_for('system_admin_users'))
company = user.company
# Verify no alternative admin exists
alternative_admin = User.query.filter(
User.company_id == company.id,
User.role.in_([Role.ADMIN, Role.SUPERVISOR]),
User.id != user_id
).first()
if alternative_admin:
flash('Alternative admin found. Regular user deletion should be used instead.', 'error')
return redirect(url_for('admin_users') if g.user.role != Role.SYSTEM_ADMIN else url_for('system_admin_users'))
if request.method == 'POST':
# Verify company name confirmation
company_name_confirm = request.form.get('company_name_confirm', '').strip()
understand_deletion = request.form.get('understand_deletion')
if company_name_confirm != company.name:
flash('Company name confirmation does not match', 'error')
return redirect(url_for('confirm_company_deletion', user_id=user_id))
if not understand_deletion:
flash('You must confirm that you understand the consequences', 'error')
return redirect(url_for('confirm_company_deletion', user_id=user_id))
try:
# Perform cascade deletion
company_name = company.name
# Delete all company-related data in the correct order
# First, clear foreign key references that could cause constraint violations
# 1. Clear task references in kanban cards (they reference tasks)
KanbanCard.query.filter(KanbanCard.column_id.in_(
db.session.query(KanbanColumn.id).filter(KanbanColumn.board_id.in_(
db.session.query(KanbanBoard.id).filter(KanbanBoard.company_id == company.id)
))
)).update({'task_id': None}, synchronize_session=False)
# 2. Delete time entries (they reference tasks and users)
TimeEntry.query.filter(TimeEntry.user_id.in_(
db.session.query(User.id).filter(User.company_id == company.id)
)).delete(synchronize_session=False)
# 3. Delete user preferences and dashboards
UserPreferences.query.filter(UserPreferences.user_id.in_(
db.session.query(User.id).filter(User.company_id == company.id)
)).delete(synchronize_session=False)
UserDashboard.query.filter(UserDashboard.user_id.in_(
db.session.query(User.id).filter(User.company_id == company.id)
)).delete(synchronize_session=False)
# 4. Delete work configs
WorkConfig.query.filter(WorkConfig.user_id.in_(
db.session.query(User.id).filter(User.company_id == company.id)
)).delete(synchronize_session=False)
# 5. Delete kanban cards (now safe since task references are cleared)
KanbanCard.query.filter(KanbanCard.column_id.in_(
db.session.query(KanbanColumn.id).filter(KanbanColumn.board_id.in_(
db.session.query(KanbanBoard.id).filter(KanbanBoard.company_id == company.id)
))
)).delete(synchronize_session=False)
# 6. Delete kanban columns (they depend on boards)
KanbanColumn.query.filter(KanbanColumn.board_id.in_(
db.session.query(KanbanBoard.id).filter(KanbanBoard.company_id == company.id)
)).delete(synchronize_session=False)
# 7. Delete kanban boards
KanbanBoard.query.filter_by(company_id=company.id).delete()
# 8. Delete subtasks (they depend on tasks)
SubTask.query.filter(SubTask.task_id.in_(
db.session.query(Task.id).filter(Task.project_id.in_(
db.session.query(Project.id).filter(Project.company_id == company.id)
))
)).delete(synchronize_session=False)
# 9. Delete tasks (now safe since kanban cards and subtasks are deleted)
Task.query.filter(Task.project_id.in_(
db.session.query(Project.id).filter(Project.company_id == company.id)
)).delete(synchronize_session=False)
# 10. Delete projects
Project.query.filter_by(company_id=company.id).delete()
# 11. Delete project categories
ProjectCategory.query.filter_by(company_id=company.id).delete()
# 12. Delete company work config
CompanyWorkConfig.query.filter_by(company_id=company.id).delete()
# 13. Delete teams
Team.query.filter_by(company_id=company.id).delete()
# 14. Delete users
User.query.filter_by(company_id=company.id).delete()
# 15. Delete system events for this company
SystemEvent.query.filter_by(company_id=company.id).delete()
# 16. Finally, delete the company itself
db.session.delete(company)
db.session.commit()
flash(f'Company "{company_name}" and all associated data has been permanently deleted', 'success')
# Log the deletion
SystemEvent.log_event(
event_type='company_deleted',
description=f'Company "{company_name}" was deleted by {g.user.username} due to no alternative admin for user deletion',
event_category='admin_action',
severity='warning',
user_id=g.user.id
)
return redirect(url_for('system_admin_companies') if g.user.role == Role.SYSTEM_ADMIN else url_for('index'))
except Exception as e:
db.session.rollback()
logger.error(f"Error deleting company {company.id}: {str(e)}")
flash(f'Error deleting company: {str(e)}', 'error')
return redirect(url_for('confirm_company_deletion', user_id=user_id))
# GET request - show confirmation page
# Gather all data that will be deleted
users = User.query.filter_by(company_id=company.id).all()
teams = Team.query.filter_by(company_id=company.id).all()
projects = Project.query.filter_by(company_id=company.id).all()
categories = ProjectCategory.query.filter_by(company_id=company.id).all()
kanban_boards = KanbanBoard.query.filter_by(company_id=company.id).all()
# Get tasks for all projects in the company
project_ids = [p.id for p in projects]
tasks = Task.query.filter(Task.project_id.in_(project_ids)).all() if project_ids else []
# Count time entries
user_ids = [u.id for u in users]
time_entries_count = TimeEntry.query.filter(TimeEntry.user_id.in_(user_ids)).count() if user_ids else 0
# Calculate total hours
total_duration = db.session.query(func.sum(TimeEntry.duration)).filter(
TimeEntry.user_id.in_(user_ids)
).scalar() or 0
total_hours_tracked = round(total_duration / 3600, 2) if total_duration else 0
return render_template('confirm_company_deletion.html',
user=user,
company=company,
users=users,
teams=teams,
projects=projects,
categories=categories,
kanban_boards=kanban_boards,
tasks=tasks,
time_entries_count=time_entries_count,
total_hours_tracked=total_hours_tracked)
@app.route('/profile', methods=['GET', 'POST']) @app.route('/profile', methods=['GET', 'POST'])
@login_required @login_required
def profile(): def profile():
@@ -1870,15 +2107,62 @@ def system_admin_delete_user(user_id):
username = user.username username = user.username
company_name = user.company.name if user.company else 'Unknown' company_name = user.company.name if user.company else 'Unknown'
# Delete related data first try:
TimeEntry.query.filter_by(user_id=user.id).delete() # Handle dependent records before deleting user
WorkConfig.query.filter_by(user_id=user.id).delete() # Find an alternative admin/supervisor in the same company to transfer ownership to
alternative_admin = User.query.filter(
User.company_id == user.company_id,
User.role.in_([Role.ADMIN, Role.SUPERVISOR]),
User.id != user_id
).first()
# Delete the user if alternative_admin:
# Transfer ownership of projects to alternative admin
Project.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
# Transfer ownership of tasks to alternative admin
Task.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
# Transfer ownership of subtasks to alternative admin
SubTask.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
# Transfer ownership of project categories to alternative admin
ProjectCategory.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
# Transfer ownership of kanban boards to alternative admin
KanbanBoard.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
# Transfer ownership of kanban cards to alternative admin
KanbanCard.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
else:
# No alternative admin found - redirect to company deletion confirmation
flash('No other administrator or supervisor found in the same company. Company deletion required.', 'warning')
return redirect(url_for('confirm_company_deletion', user_id=user_id))
# Delete user-specific records that can be safely removed
TimeEntry.query.filter_by(user_id=user_id).delete()
WorkConfig.query.filter_by(user_id=user_id).delete()
UserPreferences.query.filter_by(user_id=user_id).delete()
# Delete user dashboards (cascades to widgets)
UserDashboard.query.filter_by(user_id=user_id).delete()
# Clear task and subtask assignments
Task.query.filter_by(assigned_to_id=user_id).update({'assigned_to_id': None})
SubTask.query.filter_by(assigned_to_id=user_id).update({'assigned_to_id': None})
KanbanCard.query.filter_by(assigned_to_id=user_id).update({'assigned_to_id': None})
# Now safe to delete the user
db.session.delete(user) db.session.delete(user)
db.session.commit() db.session.commit()
flash(f'User "{username}" from company "{company_name}" has been deleted.', 'success') flash(f'User "{username}" from company "{company_name}" has been deleted. Projects and tasks transferred to {alternative_admin.username}', 'success')
except Exception as e:
db.session.rollback()
logger.error(f"Error deleting user {user_id}: {str(e)}")
flash(f'Error deleting user: {str(e)}', 'error')
return redirect(url_for('system_admin_users')) return redirect(url_for('system_admin_users'))
@app.route('/system-admin/companies') @app.route('/system-admin/companies')
@@ -3352,8 +3636,8 @@ def project_kanban(project_id):
flash('You do not have access to this project.', 'error') flash('You do not have access to this project.', 'error')
return redirect(url_for('admin_projects')) return redirect(url_for('admin_projects'))
# Get all Kanban boards for this project # Get all Kanban boards for this company (unified boards)
boards = KanbanBoard.query.filter_by(project_id=project_id, is_active=True).order_by(KanbanBoard.created_at.desc()).all() boards = KanbanBoard.query.filter_by(company_id=g.user.company_id, is_active=True).order_by(KanbanBoard.created_at.desc()).all()
# Get team members for assignment dropdown # Get team members for assignment dropdown
if project.team_id: if project.team_id:
@@ -3364,18 +3648,98 @@ def project_kanban(project_id):
# Get tasks for task assignment dropdown # Get tasks for task assignment dropdown
tasks = Task.query.filter_by(project_id=project_id).order_by(Task.name).all() tasks = Task.query.filter_by(project_id=project_id).order_by(Task.name).all()
# Get available projects for card assignment dropdown
available_projects = []
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
# Admins and Supervisors can see all company projects
all_projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).order_by(Project.name).all()
available_projects = [p for p in all_projects if p.is_user_allowed(g.user)]
elif g.user.team_id:
# Team members see team projects + unassigned projects
all_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()
available_projects = [p for p in all_projects if p.is_user_allowed(g.user)]
else:
# Unassigned users see only unassigned projects
all_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 all_projects if p.is_user_allowed(g.user)]
return render_template('project_kanban.html', return render_template('project_kanban.html',
title=f'Kanban - {project.name}', title=f'Kanban - {project.name}',
project=project, project=project,
boards=boards, boards=boards,
team_members=team_members, team_members=team_members,
tasks=tasks) tasks=tasks,
available_projects=available_projects)
@app.route('/kanban') @app.route('/kanban')
@role_required(Role.TEAM_MEMBER) @role_required(Role.TEAM_MEMBER)
@company_required @company_required
def kanban_overview(): def kanban_overview():
# Get all projects the user has access to # Check if create=true parameter is present
create_board = request.args.get('create') == 'true'
# Check if board parameter is present (for viewing a specific board)
board_id = request.args.get('board')
if board_id:
try:
board_id = int(board_id)
# Verify board exists and user has access
board = KanbanBoard.query.filter_by(id=board_id, company_id=g.user.company_id, is_active=True).first()
if board:
# Get team members for assignment dropdown
team_members = User.query.filter_by(company_id=g.user.company_id).all()
# Get all boards for the dropdown (so users can switch between boards)
all_boards = KanbanBoard.query.filter_by(company_id=g.user.company_id, is_active=True).order_by(KanbanBoard.created_at.desc()).all()
# Get available projects for card assignment dropdown
available_projects = []
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
# Admins and Supervisors can see all company projects
all_projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).order_by(Project.name).all()
available_projects = [p for p in all_projects if p.is_user_allowed(g.user)]
elif g.user.team_id:
# Team members see team projects + unassigned projects
all_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()
available_projects = [p for p in all_projects if p.is_user_allowed(g.user)]
else:
# Unassigned users see only unassigned projects
all_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 all_projects if p.is_user_allowed(g.user)]
# Render the board view template
return render_template('project_kanban.html',
title=f'Kanban - {board.name}',
project=None, # No specific project context
boards=all_boards, # Pass all boards for dropdown
team_members=team_members,
tasks=[], # No project-specific tasks
available_projects=available_projects,
selected_board_id=board_id)
else:
flash('Board not found or access denied.', 'error')
return redirect(url_for('kanban_overview'))
except ValueError:
flash('Invalid board ID.', 'error')
return redirect(url_for('kanban_overview'))
# Get all projects the user has access to (for context)
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]: if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
# Admins and Supervisors can see all company projects # 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() projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).order_by(Project.name).all()
@@ -3394,20 +3758,17 @@ def kanban_overview():
is_active=True is_active=True
).order_by(Project.name).all() ).order_by(Project.name).all()
# Get Kanban boards for each project # Get all company Kanban boards (unified boards)
project_boards = {}
for project in projects:
boards = KanbanBoard.query.filter_by( boards = KanbanBoard.query.filter_by(
project_id=project.id, company_id=g.user.company_id,
is_active=True is_active=True
).order_by(KanbanBoard.created_at.desc()).all() ).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', return render_template('kanban_overview.html',
title='Kanban Overview', title='Kanban Overview',
project_boards=project_boards) boards=boards,
projects=projects,
create_board=create_board)
# Task API Routes # Task API Routes
@app.route('/api/tasks', methods=['POST']) @app.route('/api/tasks', methods=['POST'])
@@ -3840,16 +4201,11 @@ def get_kanban_stats():
is_active=True is_active=True
).all() ).all()
# Count boards and cards # Count company boards and cards
total_boards = 0 boards = KanbanBoard.query.filter_by(company_id=g.user.company_id, is_active=True).all()
total_boards = len(boards)
total_cards = 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 board in boards:
for column in board.columns: for column in board.columns:
total_cards += len([card for card in column.cards if card.is_active]) total_cards += len([card for card in column.cards if card.is_active])
@@ -3857,9 +4213,9 @@ def get_kanban_stats():
return jsonify({ return jsonify({
'success': True, 'success': True,
'stats': { 'stats': {
'projects_with_boards': projects_with_boards,
'total_boards': total_boards, 'total_boards': total_boards,
'total_cards': total_cards 'total_cards': total_cards,
'total_projects': len(projects)
} }
}) })
@@ -3871,14 +4227,21 @@ def get_kanban_stats():
@company_required @company_required
def get_kanban_boards(): def get_kanban_boards():
try: try:
# Optional project_id filter for backward compatibility
project_id = request.args.get('project_id') project_id = request.args.get('project_id')
# Verify project access # Base query for company boards
boards_query = KanbanBoard.query.filter_by(company_id=g.user.company_id, is_active=True)
# If project_id is provided, verify access and filter
if project_id:
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first() 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): if not project or not project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Project not found or access denied'}) return jsonify({'success': False, 'message': 'Project not found or access denied'})
# For backward compatibility, we can still filter by project context
# but boards are now company-wide
boards = KanbanBoard.query.filter_by(project_id=project_id, is_active=True).all() boards = boards_query.all()
boards_data = [] boards_data = []
for board in boards: for board in boards:
@@ -3902,9 +4265,12 @@ def get_kanban_boards():
def create_kanban_board(): def create_kanban_board():
try: try:
data = request.get_json() data = request.get_json()
project_id = int(data.get('project_id'))
# Verify project access # Optional project_id for backward compatibility
project_id = data.get('project_id')
if project_id:
project_id = int(project_id)
# Verify project access if provided
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first() 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): if not project or not project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Project not found or access denied'}) return jsonify({'success': False, 'message': 'Project not found or access denied'})
@@ -3913,16 +4279,16 @@ def create_kanban_board():
if not name: if not name:
return jsonify({'success': False, 'message': 'Board name is required'}) return jsonify({'success': False, 'message': 'Board name is required'})
# Check if board name already exists in project # Check if board name already exists in company
existing = KanbanBoard.query.filter_by(project_id=project_id, name=name).first() existing = KanbanBoard.query.filter_by(company_id=g.user.company_id, name=name).first()
if existing: if existing:
return jsonify({'success': False, 'message': 'Board name already exists in this project'}) return jsonify({'success': False, 'message': 'Board name already exists in this company'})
# Create board # Create board
board = KanbanBoard( board = KanbanBoard(
name=name, name=name,
description=data.get('description', ''), description=data.get('description', ''),
project_id=project_id, company_id=g.user.company_id,
is_default=data.get('is_default') in ['true', 'on', True], is_default=data.get('is_default') in ['true', 'on', True],
created_by_id=g.user.id created_by_id=g.user.id
) )
@@ -3959,12 +4325,12 @@ def create_kanban_board():
@company_required @company_required
def get_kanban_board(board_id): def get_kanban_board(board_id):
try: try:
board = KanbanBoard.query.join(Project).filter( board = KanbanBoard.query.filter(
KanbanBoard.id == board_id, KanbanBoard.id == board_id,
Project.company_id == g.user.company_id KanbanBoard.company_id == g.user.company_id
).first() ).first()
if not board or not board.project.is_user_allowed(g.user): if not board:
return jsonify({'success': False, 'message': 'Board not found or access denied'}) return jsonify({'success': False, 'message': 'Board not found or access denied'})
columns_data = [] columns_data = []
@@ -3987,6 +4353,11 @@ def get_kanban_board(board_id):
'id': card.assigned_to.id, 'id': card.assigned_to.id,
'username': card.assigned_to.username 'username': card.assigned_to.username
} if card.assigned_to else None, } if card.assigned_to else None,
'project': {
'id': card.project.id,
'name': card.project.name,
'code': card.project.code
} if card.project else None,
'task_id': card.task_id, 'task_id': card.task_id,
'task_name': card.task.name if card.task else None, 'task_name': card.task.name if card.task else None,
'due_date': card.due_date.isoformat() if card.due_date else None, 'due_date': card.due_date.isoformat() if card.due_date else None,
@@ -4009,10 +4380,9 @@ def get_kanban_board(board_id):
'id': board.id, 'id': board.id,
'name': board.name, 'name': board.name,
'description': board.description, 'description': board.description,
'project': { 'company': {
'id': board.project.id, 'id': board.company.id,
'name': board.project.name, 'name': board.company.name
'code': board.project.code
}, },
'columns': columns_data 'columns': columns_data
} }
@@ -4031,12 +4401,12 @@ def create_kanban_card():
column_id = data.get('column_id') column_id = data.get('column_id')
# Verify column access # Verify column access
column = KanbanColumn.query.join(KanbanBoard).join(Project).filter( column = KanbanColumn.query.join(KanbanBoard).filter(
KanbanColumn.id == column_id, KanbanColumn.id == column_id,
Project.company_id == g.user.company_id KanbanBoard.company_id == g.user.company_id
).first() ).first()
if not column or not column.board.project.is_user_allowed(g.user): if not column:
return jsonify({'success': False, 'message': 'Column not found or access denied'}) return jsonify({'success': False, 'message': 'Column not found or access denied'})
title = data.get('title') title = data.get('title')
@@ -4053,6 +4423,14 @@ def create_kanban_card():
if data.get('due_date'): if data.get('due_date'):
due_date = datetime.strptime(data.get('due_date'), '%Y-%m-%d').date() due_date = datetime.strptime(data.get('due_date'), '%Y-%m-%d').date()
# Verify project access if project_id is provided
project_id = None
if data.get('project_id'):
project_id = int(data.get('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 card # Create card
card = KanbanCard( card = KanbanCard(
title=title, title=title,
@@ -4060,6 +4438,7 @@ def create_kanban_card():
position=max_position + 1, position=max_position + 1,
color=data.get('color'), color=data.get('color'),
column_id=column_id, column_id=column_id,
project_id=project_id,
task_id=int(data.get('task_id')) if data.get('task_id') else None, 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, assigned_to_id=int(data.get('assigned_to_id')) if data.get('assigned_to_id') else None,
due_date=due_date, due_date=due_date,
@@ -4085,21 +4464,21 @@ def move_kanban_card(card_id):
new_position = data.get('position') new_position = data.get('position')
# Verify card access # Verify card access
card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).join(Project).filter( card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).filter(
KanbanCard.id == card_id, KanbanCard.id == card_id,
Project.company_id == g.user.company_id KanbanBoard.company_id == g.user.company_id
).first() ).first()
if not card or not card.can_user_access(g.user): if not card:
return jsonify({'success': False, 'message': 'Card not found or access denied'}) return jsonify({'success': False, 'message': 'Card not found or access denied'})
# Verify new column access # Verify new column access
new_column = KanbanColumn.query.join(KanbanBoard).join(Project).filter( new_column = KanbanColumn.query.join(KanbanBoard).filter(
KanbanColumn.id == new_column_id, KanbanColumn.id == new_column_id,
Project.company_id == g.user.company_id KanbanBoard.company_id == g.user.company_id
).first() ).first()
if not new_column or not new_column.board.project.is_user_allowed(g.user): if not new_column:
return jsonify({'success': False, 'message': 'Target column not found or access denied'}) return jsonify({'success': False, 'message': 'Target column not found or access denied'})
old_column_id = card.column_id old_column_id = card.column_id
@@ -4146,12 +4525,12 @@ def move_kanban_card(card_id):
@company_required @company_required
def update_kanban_card(card_id): def update_kanban_card(card_id):
try: try:
card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).join(Project).filter( card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).filter(
KanbanCard.id == card_id, KanbanCard.id == card_id,
Project.company_id == g.user.company_id KanbanBoard.company_id == g.user.company_id
).first() ).first()
if not card or not card.can_user_access(g.user): if not card:
return jsonify({'success': False, 'message': 'Card not found or access denied'}) return jsonify({'success': False, 'message': 'Card not found or access denied'})
data = request.get_json() data = request.get_json()
@@ -4169,6 +4548,16 @@ def update_kanban_card(card_id):
card.task_id = int(data['task_id']) if data['task_id'] else None card.task_id = int(data['task_id']) if data['task_id'] else None
if 'due_date' in data: if 'due_date' in data:
card.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date() if data['due_date'] else None card.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date() if data['due_date'] else None
if 'project_id' in data:
# Verify project access if project_id is provided
if data['project_id']:
project_id = int(data['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'})
card.project_id = project_id
else:
card.project_id = None
card.updated_at = datetime.now() card.updated_at = datetime.now()
db.session.commit() db.session.commit()
@@ -4184,12 +4573,12 @@ def update_kanban_card(card_id):
@company_required @company_required
def delete_kanban_card(card_id): def delete_kanban_card(card_id):
try: try:
card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).join(Project).filter( card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).filter(
KanbanCard.id == card_id, KanbanCard.id == card_id,
Project.company_id == g.user.company_id KanbanBoard.company_id == g.user.company_id
).first() ).first()
if not card or not card.can_user_access(g.user): if not card:
return jsonify({'success': False, 'message': 'Card not found or access denied'}) return jsonify({'success': False, 'message': 'Card not found or access denied'})
column_id = card.column_id column_id = card.column_id
@@ -4227,12 +4616,12 @@ def create_kanban_column():
board_id = int(data.get('board_id')) board_id = int(data.get('board_id'))
# Verify board access # Verify board access
board = KanbanBoard.query.join(Project).filter( board = KanbanBoard.query.filter(
KanbanBoard.id == board_id, KanbanBoard.id == board_id,
Project.company_id == g.user.company_id KanbanBoard.company_id == g.user.company_id
).first() ).first()
if not board or not board.project.is_user_allowed(g.user): if not board:
return jsonify({'success': False, 'message': 'Board not found or access denied'}) return jsonify({'success': False, 'message': 'Board not found or access denied'})
name = data.get('name') name = data.get('name')
@@ -4273,12 +4662,12 @@ def create_kanban_column():
@company_required @company_required
def update_kanban_column(column_id): def update_kanban_column(column_id):
try: try:
column = KanbanColumn.query.join(KanbanBoard).join(Project).filter( column = KanbanColumn.query.join(KanbanBoard).filter(
KanbanColumn.id == column_id, KanbanColumn.id == column_id,
Project.company_id == g.user.company_id KanbanBoard.company_id == g.user.company_id
).first() ).first()
if not column or not column.board.project.is_user_allowed(g.user): if not column:
return jsonify({'success': False, 'message': 'Column not found or access denied'}) return jsonify({'success': False, 'message': 'Column not found or access denied'})
data = request.get_json() data = request.get_json()
@@ -4321,12 +4710,12 @@ def move_kanban_column(column_id):
new_position = int(data.get('position')) new_position = int(data.get('position'))
# Verify column access # Verify column access
column = KanbanColumn.query.join(KanbanBoard).join(Project).filter( column = KanbanColumn.query.join(KanbanBoard).filter(
KanbanColumn.id == column_id, KanbanColumn.id == column_id,
Project.company_id == g.user.company_id KanbanBoard.company_id == g.user.company_id
).first() ).first()
if not column or not column.board.project.is_user_allowed(g.user): if not column:
return jsonify({'success': False, 'message': 'Column not found or access denied'}) return jsonify({'success': False, 'message': 'Column not found or access denied'})
old_position = column.position old_position = column.position
@@ -4378,12 +4767,12 @@ def move_kanban_column(column_id):
@company_required @company_required
def delete_kanban_column(column_id): def delete_kanban_column(column_id):
try: try:
column = KanbanColumn.query.join(KanbanBoard).join(Project).filter( column = KanbanColumn.query.join(KanbanBoard).filter(
KanbanColumn.id == column_id, KanbanColumn.id == column_id,
Project.company_id == g.user.company_id KanbanBoard.company_id == g.user.company_id
).first() ).first()
if not column or not column.board.project.is_user_allowed(g.user): if not column:
return jsonify({'success': False, 'message': 'Column not found or access denied'}) return jsonify({'success': False, 'message': 'Column not found or access denied'})
# Check if column has active cards # Check if column has active cards
@@ -4872,18 +5261,11 @@ def get_widget_data(widget_id):
} for t in tasks] } for t in tasks]
elif widget.widget_type == WidgetType.KANBAN_SUMMARY: elif widget.widget_type == WidgetType.KANBAN_SUMMARY:
# Get kanban data summary # Get kanban data summary - company-wide boards
if g.user.team_id: boards = KanbanBoard.query.filter_by(
project_ids = [p.id for p in Project.query.filter( company_id=g.user.company_id,
Project.company_id == g.user.company_id, is_active=True
db.or_(Project.team_id == g.user.team_id, Project.team_id == None)
).all()]
boards = KanbanBoard.query.filter(
KanbanBoard.project_id.in_(project_ids),
KanbanBoard.is_active == True
).limit(3).all() ).limit(3).all()
else:
boards = []
board_summaries = [] board_summaries = []
for board in boards: for board in boards:
@@ -4893,7 +5275,7 @@ def get_widget_data(widget_id):
board_summaries.append({ board_summaries.append({
'id': board.id, 'id': board.id,
'name': board.name, 'name': board.name,
'project_name': board.project.name, 'company_name': board.company.name,
'total_cards': total_cards, 'total_cards': total_cards,
'columns': len(columns) 'columns': len(columns)
}) })

View File

@@ -729,12 +729,12 @@ class KanbanBoard(db.Model):
name = db.Column(db.String(100), nullable=False) name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)
# Project association # Company association for multi-tenancy (removed project-specific constraint)
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False) company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Board settings # Board settings
is_active = db.Column(db.Boolean, default=True) is_active = db.Column(db.Boolean, default=True)
is_default = db.Column(db.Boolean, default=False) # Default board for project is_default = db.Column(db.Boolean, default=False) # Default board for company
# Metadata # Metadata
created_at = db.Column(db.DateTime, default=datetime.now) created_at = db.Column(db.DateTime, default=datetime.now)
@@ -742,12 +742,12 @@ class KanbanBoard(db.Model):
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships # Relationships
project = db.relationship('Project', backref='kanban_boards') company = db.relationship('Company', backref='kanban_boards')
created_by = db.relationship('User', foreign_keys=[created_by_id]) 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') columns = db.relationship('KanbanColumn', backref='board', lazy=True, cascade='all, delete-orphan', order_by='KanbanColumn.position')
# Unique constraint per project # Unique constraint per company
__table_args__ = (db.UniqueConstraint('project_id', 'name', name='uq_kanban_board_name_per_project'),) __table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_kanban_board_name_per_company'),)
def __repr__(self): def __repr__(self):
return f'<KanbanBoard {self.name}>' return f'<KanbanBoard {self.name}>'
@@ -804,6 +804,9 @@ class KanbanCard(db.Model):
# Column association # Column association
column_id = db.Column(db.Integer, db.ForeignKey('kanban_column.id'), nullable=False) column_id = db.Column(db.Integer, db.ForeignKey('kanban_column.id'), nullable=False)
# Project context for cross-project support
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
# Optional task association # Optional task association
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True) task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True)
@@ -820,6 +823,7 @@ class KanbanCard(db.Model):
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships # Relationships
project = db.relationship('Project', backref='kanban_cards')
task = db.relationship('Task', backref='kanban_cards') task = db.relationship('Task', backref='kanban_cards')
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_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]) created_by = db.relationship('User', foreign_keys=[created_by_id])
@@ -829,8 +833,26 @@ class KanbanCard(db.Model):
def can_user_access(self, user): def can_user_access(self, user):
"""Check if a user can access this card""" """Check if a user can access this card"""
# Check board's project permissions # Check company membership first
return self.column.board.project.is_user_allowed(user) if self.column.board.company_id != user.company_id:
return False
# If card has project context, check project permissions
if self.project_id:
return self.project.is_user_allowed(user)
# If no project context, allow access to anyone in the company
return True
@property
def project_code(self):
"""Get project code for display purposes"""
return self.project.code if self.project else None
@property
def project_name(self):
"""Get project name for display purposes"""
return self.project.name if self.project else None
# Dashboard Widget System # Dashboard Widget System
class WidgetType(enum.Enum): class WidgetType(enum.Enum):

View File

@@ -0,0 +1,269 @@
{% extends "layout.html" %}
{% block content %}
<div class="admin-container">
<div class="header-section">
<h1>⚠️ Confirm Company Deletion</h1>
<p class="subtitle">Critical Action Required - Review All Data Before Proceeding</p>
<a href="{{ url_for('admin_users') if g.user.role != Role.SYSTEM_ADMIN else url_for('system_admin_users') }}"
class="btn btn-secondary">← Back to User Management</a>
</div>
<div class="alert alert-danger">
<h3>Critical Warning!</h3>
<p>You are about to delete user <strong>{{ user.username }}</strong> who is the last administrator/supervisor in company <strong>{{ company.name }}</strong>.</p>
<p><strong>This action will permanently delete the entire company and ALL associated data.</strong></p>
<p>This action cannot be undone!</p>
</div>
<div class="content-section">
<h2>The following data will be permanently deleted:</h2>
<!-- Company Information -->
<div class="table-section">
<h3>🏢 Company Information</h3>
<table class="data-table">
<tr>
<th>Company Name:</th>
<td>{{ company.name }}</td>
</tr>
<tr>
<th>Company Slug:</th>
<td>{{ company.slug }}</td>
</tr>
<tr>
<th>Created:</th>
<td>{{ company.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
<tr>
<th>Description:</th>
<td>{{ company.description or 'None' }}</td>
</tr>
</table>
</div>
<!-- Users -->
{% if users %}
<div class="table-section">
<h3>👥 Users ({{ users|length }})</h3>
<table class="data-table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Team</th>
<th>Joined</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr {% if u.id == user.id %}class="highlight-row"{% endif %}>
<td>
{{ u.username }}
{% if u.id == user.id %}<span class="status-badge status-warning">Target User</span>{% endif %}
</td>
<td>{{ u.email }}</td>
<td>{{ u.role.value }}</td>
<td>{{ u.team.name if u.team else 'None' }}</td>
<td>{{ u.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<span class="status-badge {% if u.is_blocked %}status-blocked{% else %}status-active{% endif %}">
{% if u.is_blocked %}Blocked{% else %}Active{% endif %}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Teams -->
{% if teams %}
<div class="table-section">
<h3>🏭 Teams ({{ teams|length }})</h3>
<table class="data-table">
<thead>
<tr>
<th>Team Name</th>
<th>Description</th>
<th>Members</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{% for team in teams %}
<tr>
<td>{{ team.name }}</td>
<td>{{ team.description or 'None' }}</td>
<td>{{ team.users|length }}</td>
<td>{{ team.created_at.strftime('%Y-%m-%d') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Projects -->
{% if projects %}
<div class="table-section">
<h3>📝 Projects ({{ projects|length }})</h3>
<table class="data-table">
<thead>
<tr>
<th>Project Code</th>
<th>Project Name</th>
<th>Team</th>
<th>Tasks</th>
<th>Time Entries</th>
<th>Created By</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr>
<td>{{ project.code }}</td>
<td>{{ project.name }}</td>
<td>{{ project.team.name if project.team else 'None' }}</td>
<td>{{ project.tasks|length }}</td>
<td>{{ project.time_entries|length }}</td>
<td>{{ project.created_by.username }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Tasks -->
{% if tasks %}
<div class="table-section">
<h3>✅ Tasks ({{ tasks|length }})</h3>
<table class="data-table">
<thead>
<tr>
<th>Task Name</th>
<th>Project</th>
<th>Status</th>
<th>Priority</th>
<th>Assigned To</th>
<th>Subtasks</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td>{{ task.name }}</td>
<td>{{ task.project.code }}</td>
<td>{{ task.status.value }}</td>
<td>{{ task.priority.value }}</td>
<td>{{ task.assigned_to.username if task.assigned_to else 'None' }}</td>
<td>{{ task.subtasks|length }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Kanban Boards -->
{% if kanban_boards %}
<div class="table-section">
<h3>📋 Kanban Boards ({{ kanban_boards|length }})</h3>
<table class="data-table">
<thead>
<tr>
<th>Board Name</th>
<th>Columns</th>
<th>Cards</th>
<th>Created By</th>
</tr>
</thead>
<tbody>
{% for board in kanban_boards %}
<tr>
<td>{{ board.name }}</td>
<td>{{ board.columns|length }}</td>
<td>{{ board.columns|map(attribute='cards')|map('length')|sum }}</td>
<td>{{ board.created_by.username }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Time Entries -->
{% if time_entries_count > 0 %}
<div class="table-section">
<h3>⏱️ Time Entries ({{ time_entries_count }})</h3>
<div class="info-card">
<p>{{ time_entries_count }} time tracking entries will be permanently deleted.</p>
<p><strong>Total Hours Tracked:</strong> {{ total_hours_tracked }} hours</p>
</div>
</div>
{% endif %}
<!-- Categories -->
{% if categories %}
<div class="table-section">
<h3>🏷️ Project Categories ({{ categories|length }})</h3>
<table class="data-table">
<thead>
<tr>
<th>Category Name</th>
<th>Projects</th>
<th>Created By</th>
</tr>
</thead>
<tbody>
{% for category in categories %}
<tr>
<td>{{ category.name }}</td>
<td>{{ category.projects|length }}</td>
<td>{{ category.created_by.username }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
<!-- Confirmation Form -->
<div class="form-section">
<div class="alert alert-danger">
<h3>Final Confirmation Required</h3>
</div>
<form method="POST" action="{{ url_for('confirm_company_deletion', user_id=user.id) }}" class="user-form">
<div class="form-group">
<label for="company_name_confirm">
To confirm deletion, please type the company name: <strong>{{ company.name }}</strong>
</label>
<input type="text" class="form-control" id="company_name_confirm"
name="company_name_confirm" required
placeholder="Enter company name exactly as shown above">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="understand_deletion" name="understand_deletion" required>
I understand that this action will permanently delete the company and ALL associated data, and this cannot be undone.
</label>
</div>
<div class="form-group">
<a href="{{ url_for('admin_users') if g.user.role != Role.SYSTEM_ADMIN else url_for('system_admin_users') }}"
class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-danger">
Delete Company and All Data
</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -3,76 +3,98 @@
{% block content %} {% block content %}
<div class="kanban-overview-container"> <div class="kanban-overview-container">
<div class="overview-header"> <div class="overview-header">
<h2>Kanban Board Overview</h2> <h2>Unified Kanban Boards</h2>
<p class="overview-description">Manage your tasks visually across all projects</p> <p class="overview-description">Organize tasks from any project on shared company-wide boards</p>
</div> </div>
{% if project_boards %} {% if create_board %}
<div class="projects-grid"> <!-- Create Board Form -->
{% for project, boards in project_boards.items() %} <div class="create-board-form">
<div class="project-card"> <div class="form-header">
<div class="project-header"> <h3>Create New Kanban Board</h3>
<div class="project-info"> <button type="button" id="cancel-create-board" class="btn btn-secondary">Cancel</button>
<h3 class="project-name"> </div>
<span class="project-code">{{ project.code }}</span> <form id="create-board-form" class="board-form">
{{ project.name }} <div class="form-group">
</h3> <label for="board-name">Board Name <span class="required">*</span></label>
{% if project.category %} <input type="text" id="board-name" name="name" required placeholder="Enter board name">
<span class="category-badge" style="background-color: {{ project.category.color }}20; color: {{ project.category.color }};"> </div>
{{ project.category.icon or '📁' }} {{ project.category.name }} <div class="form-group">
</span> <label for="board-description">Description</label>
<textarea id="board-description" name="description" placeholder="Optional description"></textarea>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="board-is-default" name="is_default">
Set as default board
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Create Board</button>
</div>
</form>
</div>
{% endif %} {% 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"> {% if boards %}
<h4>Kanban Boards ({{ boards|length }})</h4> <div class="boards-grid">
<div class="boards-list">
{% for board in boards %} {% for board in boards %}
<div class="board-item" onclick="openBoard({{ project.id }}, {{ board.id }})"> <div class="board-card">
<div class="board-header">
<div class="board-info"> <div class="board-info">
<div class="board-name"> <h3 class="board-name">
{{ board.name }} {{ board.name }}
{% if board.is_default %} {% if board.is_default %}
<span class="default-badge">Default</span> <span class="default-badge">Default</span>
{% endif %} {% endif %}
</div> </h3>
{% if board.description %} {% if board.description %}
<div class="board-description">{{ board.description }}</div> <p class="board-description">{{ board.description }}</p>
{% endif %} {% endif %}
</div> </div>
<div class="board-stats"> <div class="board-actions">
<span class="column-count">{{ board.columns|length }} columns</span> <a href="{{ url_for('kanban_overview') }}?board={{ board.id }}" class="btn btn-primary">Open Board</a>
<span class="card-count"> {% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %}
<button class="btn btn-secondary" onclick="editBoard({{ board.id }})">Edit</button>
{% endif %}
</div>
</div>
<div class="board-stats-section">
<div class="stat-grid">
<div class="stat-item">
<span class="stat-number">{{ board.columns|length }}</span>
<span class="stat-label">Columns</span>
</div>
<div class="stat-item">
{% set board_cards = 0 %} {% set board_cards = 0 %}
{% for column in board.columns %} {% for column in board.columns %}
{% set board_cards = board_cards + column.cards|selectattr('is_active')|list|length %} {% set board_cards = board_cards + column.cards|selectattr('is_active')|list|length %}
{% endfor %} {% endfor %}
{{ board_cards }} cards <span class="stat-number">{{ board_cards }}</span>
</span> <span class="stat-label">Cards</span>
</div>
</div> </div>
<div class="stat-item">
{% set project_contexts = [] %}
{% for column in board.columns %}
{% for card in column.cards %}
{% if card.is_active and card.project_id and card.project_id not in project_contexts %}
{% set _ = project_contexts.append(card.project_id) %}
{% endif %}
{% endfor %} {% endfor %}
{% endfor %}
<span class="stat-number">{{ project_contexts|length }}</span>
<span class="stat-label">Projects</span>
</div>
</div> </div>
</div> </div>
<!-- Quick Board Preview --> <!-- Quick Board Preview -->
<div class="board-preview"> <div class="board-preview">
{% set default_board = boards|selectattr('is_default')|first %} {% if board.columns %}
{% if not default_board %} <h5>Board Preview</h5>
{% set default_board = boards|first %}
{% endif %}
{% if default_board and default_board.columns %}
<h5>{{ default_board.name }} Preview</h5>
<div class="preview-columns"> <div class="preview-columns">
{% for column in default_board.columns %} {% for column in board.columns %}
{% if loop.index <= 4 %} {% if loop.index <= 4 %}
<div class="preview-column" style="border-top: 3px solid {{ column.color }};"> <div class="preview-column" style="border-top: 3px solid {{ column.color }};">
<div class="preview-column-header"> <div class="preview-column-header">
@@ -85,8 +107,11 @@
{% if loop.index <= 3 %} {% if loop.index <= 3 %}
<div class="preview-card" {% if card.color %}style="background-color: {{ card.color }};"{% endif %}> <div class="preview-card" {% if card.color %}style="background-color: {{ card.color }};"{% endif %}>
<div class="preview-card-title"> <div class="preview-card-title">
{% if card.title|length > 30 %} {% if card.project %}
{{ card.title|truncate(30, True) }} <span class="preview-card-project">[{{ card.project.code }}]</span>
{% endif %}
{% if card.title|length > 25 %}
{{ card.title|truncate(25, True) }}
{% else %} {% else %}
{{ card.title }} {{ card.title }}
{% endif %} {% endif %}
@@ -104,9 +129,9 @@
</div> </div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if default_board.columns|length > 4 %} {% if board.columns|length > 4 %}
<div class="preview-more-columns"> <div class="preview-more-columns">
+{{ default_board.columns|length - 4 }} more columns +{{ board.columns|length - 4 }} more columns
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -119,29 +144,33 @@
<!-- Quick Stats --> <!-- Quick Stats -->
<div class="quick-stats"> <div class="quick-stats">
<div class="stat-card"> <div class="stat-card">
<h3>{{ project_boards.keys()|list|length }}</h3> <h3>{{ boards|length }}</h3>
<p>Projects with Kanban</p> <p>Company Boards</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>
<div class="stat-card"> <div class="stat-card">
{% set total_cards = 0 %} {% set total_cards = 0 %}
{% for project, boards in project_boards.items() %}
{% for board in boards %} {% for board in boards %}
{% for column in board.columns %} {% for column in board.columns %}
{% set total_cards = total_cards + column.cards|selectattr('is_active')|list|length %} {% set total_cards = total_cards + column.cards|selectattr('is_active')|list|length %}
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}
{% endfor %}
<h3>{{ total_cards }}</h3> <h3>{{ total_cards }}</h3>
<p>Total Cards</p> <p>Total Cards</p>
</div> </div>
<div class="stat-card">
{% set total_projects = [] %}
{% for board in boards %}
{% for column in board.columns %}
{% for card in column.cards %}
{% if card.is_active and card.project_id and card.project_id not in total_projects %}
{% set _ = total_projects.append(card.project_id) %}
{% endif %}
{% endfor %}
{% endfor %}
{% endfor %}
<h3>{{ total_projects|length }}</h3>
<p>Active Projects</p>
</div>
</div> </div>
{% else %} {% else %}
@@ -150,17 +179,17 @@
<div class="no-kanban-content"> <div class="no-kanban-content">
<div class="no-kanban-icon">📋</div> <div class="no-kanban-icon">📋</div>
<h3>No Kanban Boards Yet</h3> <h3>No Kanban Boards Yet</h3>
<p>Start organizing your projects with visual Kanban boards.</p> <p>Create unified boards to organize tasks from any project.</p>
<div class="getting-started"> <div class="getting-started">
<h4>Getting Started:</h4> <h4>Getting Started:</h4>
<ol> <ol>
<li>Go to a project from <a href="{{ url_for('admin_projects') }}">Project Management</a></li> <li>Click the <strong>"Create Board"</strong> button</li>
<li>Click the <strong>"Kanban"</strong> button</li> <li>Set up columns for your workflow (To Do, In Progress, Done)</li>
<li>Create your first board and start organizing tasks</li> <li>Add cards from any project to organize your work</li>
</ol> </ol>
</div> </div>
{% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %} {% 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> <button id="create-first-unified-board" class="btn btn-primary">Create First Board</button>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -187,13 +216,13 @@
margin: 0; margin: 0;
} }
.projects-grid { .boards-grid {
display: grid; display: grid;
gap: 2rem; gap: 2rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.project-card { .board-card {
background: white; background: white;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 12px; border-radius: 12px;
@@ -202,11 +231,11 @@
transition: box-shadow 0.2s ease; transition: box-shadow 0.2s ease;
} }
.project-card:hover { .board-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15); box-shadow: 0 4px 12px rgba(0,0,0,0.15);
} }
.project-header { .board-header {
padding: 1.5rem; padding: 1.5rem;
background: #f8f9fa; background: #f8f9fa;
border-bottom: 1px solid #dee2e6; border-bottom: 1px solid #dee2e6;
@@ -215,43 +244,65 @@
align-items: flex-start; align-items: flex-start;
} }
.project-info { .board-info {
flex: 1; flex: 1;
} }
.project-name { .board-name {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} font-size: 1.2rem;
.project-code {
background: #007bff;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600; font-weight: 600;
color: #333;
} }
.project-description { .board-description {
color: #666; color: #666;
margin: 0.5rem 0 0 0; margin: 0.5rem 0 0 0;
font-size: 0.9rem; font-size: 0.9rem;
} }
.project-actions { .board-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: flex-start; align-items: flex-start;
} }
.boards-section { .board-stats-section {
padding: 1.5rem; padding: 1.5rem;
border-bottom: 1px solid #dee2e6; border-bottom: 1px solid #dee2e6;
} }
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 1rem;
}
.stat-item {
text-align: center;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.stat-number {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: #007bff;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.8rem;
color: #666;
font-weight: 500;
}
.boards-section h4 { .boards-section h4 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: #333; color: #333;
@@ -380,6 +431,16 @@
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.preview-card-project {
background: #f3e8ff;
color: #7c3aed;
padding: 0.1rem 0.3rem;
border-radius: 3px;
font-size: 0.6rem;
font-weight: 600;
margin-right: 0.25rem;
}
.preview-card-assignee { .preview-card-assignee {
color: #666; color: #666;
font-size: 0.7rem; font-size: 0.7rem;
@@ -480,6 +541,76 @@
color: #666; color: #666;
} }
.create-board-form {
background: white;
border: 1px solid #dee2e6;
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.form-header h3 {
margin: 0;
color: #333;
}
.board-form {
max-width: 600px;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #333;
}
.form-group input[type="text"],
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #dee2e6;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s ease;
}
.form-group input[type="text"]:focus,
.form-group textarea:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
}
.form-group textarea {
height: 80px;
resize: vertical;
}
.form-group input[type="checkbox"] {
margin-right: 0.5rem;
}
.form-actions {
margin-top: 1.5rem;
}
.required {
color: #dc3545;
}
.category-badge { .category-badge {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 4px; border-radius: 4px;
@@ -488,12 +619,12 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.project-header { .board-header {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
.project-actions { .board-actions {
width: 100%; width: 100%;
justify-content: flex-start; justify-content: flex-start;
} }
@@ -505,13 +636,77 @@
.quick-stats { .quick-stats {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.stat-grid {
grid-template-columns: repeat(3, 1fr);
}
} }
</style> </style>
<script> <script>
function openBoard(projectId, boardId) { function openBoard(boardId) {
window.location.href = `/admin/projects/${projectId}/kanban?board=${boardId}`; window.location.href = `/kanban?board=${boardId}`;
} }
function editBoard(boardId) {
// Implement board editing functionality
alert('Board editing functionality to be implemented');
}
// Handle create first board button
document.addEventListener('DOMContentLoaded', function() {
const createFirstBoardBtn = document.getElementById('create-first-unified-board');
if (createFirstBoardBtn) {
createFirstBoardBtn.addEventListener('click', function() {
// Redirect to a page where they can create a board
window.location.href = '/kanban?create=true';
});
}
// Handle cancel create board
const cancelCreateBoardBtn = document.getElementById('cancel-create-board');
if (cancelCreateBoardBtn) {
cancelCreateBoardBtn.addEventListener('click', function() {
window.location.href = '/kanban';
});
}
// Handle create board form submission
const createBoardForm = document.getElementById('create-board-form');
if (createBoardForm) {
createBoardForm.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(createBoardForm);
const data = {
name: formData.get('name'),
description: formData.get('description'),
is_default: formData.get('is_default') === 'on'
};
fetch('/api/kanban/boards', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Redirect to the kanban overview page
window.location.href = '/kanban';
} else {
alert('Error creating board: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error creating board. Please try again.');
});
});
}
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -4,21 +4,32 @@
<div class="kanban-container"> <div class="kanban-container">
<div class="kanban-header"> <div class="kanban-header">
<div class="project-info"> <div class="project-info">
<h2>Kanban Board: {{ project.code }} - {{ project.name }}</h2> <h2>Unified Kanban Board</h2>
<p class="project-description">{{ project.description or 'No description available' }}</p> <p class="project-description">Organize tasks from any project on shared boards</p>
{% if project %}
<div class="project-context">
<span class="context-label">Project Context:</span>
<span class="project-code">{{ project.code }}</span>
<span class="project-name">{{ project.name }}</span>
{% if project.category %} {% if project.category %}
<span class="category-badge" style="background-color: {{ project.category.color }}20; color: {{ project.category.color }};"> <span class="category-badge" style="background-color: {{ project.category.color }}20; color: {{ project.category.color }};">
{{ project.category.icon or '📁' }} {{ project.category.name }} {{ project.category.icon or '📁' }} {{ project.category.name }}
</span> </span>
{% endif %} {% endif %}
</div> </div>
{% endif %}
</div>
<div class="kanban-actions"> <div class="kanban-actions">
{% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %} {% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %}
<button id="create-board-btn" class="btn btn-md btn-success">Create Board</button> <button id="create-board-btn" class="btn btn-md btn-success">Create Board</button>
<button id="manage-columns-btn" class="btn btn-md btn-warning" style="display: none;">Manage Columns</button> <button id="manage-columns-btn" class="btn btn-md btn-warning" style="display: none;">Manage Columns</button>
{% endif %} {% endif %}
{% if project %}
<a href="{{ url_for('admin_projects') }}" class="btn btn-md btn-secondary">Back to Projects</a> <a href="{{ url_for('admin_projects') }}" class="btn btn-md btn-secondary">Back to Projects</a>
<a href="{{ url_for('manage_project_tasks', project_id=project.id) }}" class="btn btn-md btn-info">Task View</a> <a href="{{ url_for('manage_project_tasks', project_id=project.id) }}" class="btn btn-md btn-info">Task View</a>
{% else %}
<a href="{{ url_for('kanban_overview') }}" class="btn btn-md btn-secondary">Back to Kanban Overview</a>
{% endif %}
</div> </div>
</div> </div>
@@ -29,7 +40,7 @@
<select id="board-select" class="form-control"> <select id="board-select" class="form-control">
<option value="">Choose a board...</option> <option value="">Choose a board...</option>
{% for board in boards %} {% for board in boards %}
<option value="{{ board.id }}" {% if board.is_default %}selected{% endif %}> <option value="{{ board.id }}" {% if selected_board_id and board.id == selected_board_id %}selected{% elif not selected_board_id and board.is_default %}selected{% endif %}>
{{ board.name }} {% if board.is_default %}(Default){% endif %} {{ board.name }} {% if board.is_default %}(Default){% endif %}
</option> </option>
{% endfor %} {% endfor %}
@@ -64,7 +75,9 @@
<span class="close">&times;</span> <span class="close">&times;</span>
<h3>Create Kanban Board</h3> <h3>Create Kanban Board</h3>
<form id="board-form"> <form id="board-form">
<input type="hidden" name="project_id" value="{{ project.id }}"> {% if project %}
<input type="hidden" name="project_context" value="{{ project.id }}">
{% endif %}
<div class="form-group"> <div class="form-group">
<label for="board-name">Board Name *</label> <label for="board-name">Board Name *</label>
@@ -79,7 +92,7 @@
<div class="form-group"> <div class="form-group">
<label> <label>
<input type="checkbox" id="board-is-default" name="is_default"> <input type="checkbox" id="board-is-default" name="is_default">
Set as default board for this project Set as default board for your company
</label> </label>
</div> </div>
@@ -122,22 +135,38 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="card-task">Link to Task</label> <label for="card-project">Project Context</label>
<select id="card-task" name="task_id"> <select id="card-project" name="project_id">
<option value="">No task linked</option> <option value="">No project context</option>
{% for task in tasks %} {% for project_option in available_projects %}
<option value="{{ task.id }}">{{ task.name }}</option> <option value="{{ project_option.id }}" {% if project and project_option.id == project.id %}selected{% endif %}>
[{{ project_option.code }}] {{ project_option.name }}
</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group">
<label for="card-task">Link to Task</label>
<select id="card-task" name="task_id">
<option value="">No task linked</option>
{% for task in tasks %}
<option value="{{ task.id }}" data-project-id="{{ task.project_id }}">
[{{ task.project.code }}] {{ task.name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="card-due-date">Due Date</label> <label for="card-due-date">Due Date</label>
<input type="date" id="card-due-date" name="due_date"> <input type="date" id="card-due-date" name="due_date">
</div> </div>
</div>
<div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="card-color">Card Color</label> <label for="card-color">Card Color</label>
<input type="color" id="card-color" name="color" value="#ffffff"> <input type="color" id="card-color" name="color" value="#ffffff">
@@ -231,6 +260,36 @@
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
} }
.project-context {
margin-top: 1rem;
padding: 0.75rem;
background: #f8f9fa;
border-radius: 6px;
border-left: 4px solid #007bff;
}
.context-label {
font-weight: 600;
color: #333;
margin-right: 0.5rem;
}
.project-code {
background: #007bff;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
margin-right: 0.5rem;
}
.project-name {
font-weight: 500;
color: #333;
margin-right: 0.5rem;
}
.kanban-actions { .kanban-actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
@@ -238,91 +297,7 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
/* Consistent button sizing */ /* Button styles now centralized in main style.css */
.btn-md {
padding: 0.5rem 1rem;
font-size: 0.9rem;
font-weight: 500;
line-height: 1.5;
border-radius: 6px;
border: 1px solid transparent;
text-decoration: none;
display: inline-block;
text-align: center;
vertical-align: middle;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-md:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.btn-md:active {
transform: translateY(0);
}
/* Button colors */
.btn-primary {
background-color: #007bff;
border-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
border-color: #004085;
color: white;
}
.btn-success {
background-color: #28a745;
border-color: #28a745;
color: white;
}
.btn-success:hover {
background-color: #1e7e34;
border-color: #1c7430;
color: white;
}
.btn-warning {
background-color: #ffc107;
border-color: #ffc107;
color: #212529;
}
.btn-warning:hover {
background-color: #e0a800;
border-color: #d39e00;
color: #212529;
}
.btn-info {
background-color: #17a2b8;
border-color: #17a2b8;
color: white;
}
.btn-info:hover {
background-color: #117a8b;
border-color: #10707f;
color: white;
}
.btn-secondary {
background-color: #6c757d;
border-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
border-color: #4e555b;
color: white;
}
.board-selection { .board-selection {
margin-bottom: 2rem; margin-bottom: 2rem;
@@ -438,6 +413,14 @@
font-size: 0.8rem; font-size: 0.8rem;
} }
.card-project {
background: #f3e8ff;
color: #7c3aed;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-weight: 600;
}
.card-assignee { .card-assignee {
background: #e7f3ff; background: #e7f3ff;
color: #0066cc; color: #0066cc;
@@ -716,11 +699,12 @@
<script> <script>
let currentBoard = null; let currentBoard = null;
let sortableInstances = []; let sortableInstances = [];
const userRole = '{{ g.user.role.value }}';
const canManageColumns = ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'].includes(userRole);
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const boardSelect = document.getElementById('board-select'); const boardSelect = document.getElementById('board-select');
const kanbanBoard = document.getElementById('kanban-board'); const kanbanBoard = document.getElementById('kanban-board');
const addCardBtn = document.getElementById('add-card-btn');
// Board selection handler // Board selection handler
if (boardSelect) { if (boardSelect) {
@@ -728,39 +712,37 @@ document.addEventListener('DOMContentLoaded', function() {
const boardId = this.value; const boardId = this.value;
if (boardId) { if (boardId) {
loadKanbanBoard(boardId); loadKanbanBoard(boardId);
addCardBtn.style.display = 'inline-block'; if (canManageColumns) {
{% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %} const manageBoardBtn = document.getElementById('manage-columns-btn');
document.getElementById('manage-columns-btn').style.display = 'inline-block'; if (manageBoardBtn) manageBoardBtn.style.display = 'inline-block';
{% endif %} }
} else { } else {
kanbanBoard.style.display = 'none'; kanbanBoard.style.display = 'none';
addCardBtn.style.display = 'none'; if (canManageColumns) {
{% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %} const manageBoardBtn = document.getElementById('manage-columns-btn');
document.getElementById('manage-columns-btn').style.display = 'none'; if (manageBoardBtn) manageBoardBtn.style.display = 'none';
{% endif %} }
} }
}); });
// Check URL for board parameter // Check URL for board parameter or if a board is pre-selected
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const boardFromUrl = urlParams.get('board'); const boardFromUrl = urlParams.get('board');
const selectedOption = boardSelect.querySelector('option[selected]');
if (boardFromUrl) { if (boardFromUrl) {
boardSelect.value = boardFromUrl; boardSelect.value = boardFromUrl;
loadKanbanBoard(boardFromUrl); loadKanbanBoard(boardFromUrl);
addCardBtn.style.display = 'inline-block'; if (canManageColumns) {
{% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %} const manageBoardBtn = document.getElementById('manage-columns-btn');
document.getElementById('manage-columns-btn').style.display = 'inline-block'; if (manageBoardBtn) manageBoardBtn.style.display = 'inline-block';
{% endif %} }
} else { } else if (selectedOption && selectedOption.value) {
// Load default board if exists // Load pre-selected board (either from selected_board_id or default)
const defaultOption = boardSelect.querySelector('option[selected]'); loadKanbanBoard(selectedOption.value);
if (defaultOption && defaultOption.value) { if (canManageColumns) {
loadKanbanBoard(defaultOption.value); const manageBoardBtn = document.getElementById('manage-columns-btn');
addCardBtn.style.display = 'inline-block'; if (manageBoardBtn) manageBoardBtn.style.display = 'inline-block';
{% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %}
document.getElementById('manage-columns-btn').style.display = 'inline-block';
{% endif %}
} }
} }
} }
@@ -770,14 +752,18 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
function loadKanbanBoard(boardId) { function loadKanbanBoard(boardId) {
console.log('Loading board:', boardId);
fetch(`/api/kanban/boards/${boardId}`) fetch(`/api/kanban/boards/${boardId}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
console.log('Board data received:', data);
if (data.success) { if (data.success) {
currentBoard = data.board; currentBoard = data.board;
console.log('Current board set to:', currentBoard);
renderKanbanBoard(data.board); renderKanbanBoard(data.board);
document.getElementById('kanban-board').style.display = 'block'; document.getElementById('kanban-board').style.display = 'block';
} else { } else {
console.error('Error loading board:', data.message);
alert('Error loading board: ' + data.message); alert('Error loading board: ' + data.message);
} }
}) })
@@ -855,6 +841,7 @@ function createCardHTML(card) {
<div class="card-title">${card.title}</div> <div class="card-title">${card.title}</div>
${card.description ? `<div class="card-description">${card.description}</div>` : ''} ${card.description ? `<div class="card-description">${card.description}</div>` : ''}
<div class="card-meta"> <div class="card-meta">
${card.project_code ? `<span class="card-project">[${card.project_code}]</span>` : ''}
${card.assigned_to ? `<span class="card-assignee">${card.assigned_to.username}</span>` : ''} ${card.assigned_to ? `<span class="card-assignee">${card.assigned_to.username}</span>` : ''}
${card.task_name ? `<span class="card-task-link">${card.task_name}</span>` : ''} ${card.task_name ? `<span class="card-task-link">${card.task_name}</span>` : ''}
${card.due_date ? `<span class="card-due-date ${dueDateClass}">${formatDate(card.due_date)}</span>` : ''} ${card.due_date ? `<span class="card-due-date ${dueDateClass}">${formatDate(card.due_date)}</span>` : ''}
@@ -869,6 +856,16 @@ function setupModals() {
const boardForm = document.getElementById('board-form'); const boardForm = document.getElementById('board-form');
const cardForm = document.getElementById('card-form'); const cardForm = document.getElementById('card-form');
// Check if required elements exist
if (!cardModal) {
console.error('Card modal not found');
return;
}
if (!boardModal) {
console.error('Board modal not found');
return;
}
// Board modal // Board modal
document.getElementById('create-board-btn')?.addEventListener('click', () => { document.getElementById('create-board-btn')?.addEventListener('click', () => {
boardModal.style.display = 'block'; boardModal.style.display = 'block';
@@ -891,13 +888,15 @@ function setupModals() {
}); });
// Form submissions // Form submissions
if (boardForm) {
boardForm.addEventListener('submit', function(e) { boardForm.addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
const formData = new FormData(this); const formData = new FormData(this);
const data = Object.fromEntries(formData); const data = Object.fromEntries(formData);
// Convert checkbox to boolean // Convert checkbox to boolean
data.is_default = document.getElementById('board-is-default').checked; const isDefaultCheckbox = document.getElementById('board-is-default');
data.is_default = isDefaultCheckbox ? isDefaultCheckbox.checked : false;
fetch('/api/kanban/boards', { fetch('/api/kanban/boards', {
method: 'POST', method: 'POST',
@@ -916,7 +915,9 @@ function setupModals() {
} }
}); });
}); });
}
if (cardForm) {
cardForm.addEventListener('submit', function(e){ cardForm.addEventListener('submit', function(e){
e.preventDefault(); e.preventDefault();
const formData = new FormData(this); const formData = new FormData(this);
@@ -935,22 +936,45 @@ function setupModals() {
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
console.log('Card creation response:', data);
console.log('Current board at card creation:', currentBoard);
if (data.success) { if (data.success) {
cardModal.style.display = 'none'; cardModal.style.display = 'none';
if (isEdit) { if (isEdit) {
// For edits, just reload the board to be safe // For edits, just reload the board to be safe
if (currentBoard && currentBoard.id) {
console.log('Reloading board after edit:', currentBoard.id);
loadKanbanBoard(currentBoard.id); loadKanbanBoard(currentBoard.id);
} else {
// Fallback: reload the currently selected board
const boardSelect = document.getElementById('board-select');
if (boardSelect && boardSelect.value) {
console.log('Fallback: reloading selected board after edit:', boardSelect.value);
loadKanbanBoard(boardSelect.value);
}
}
} else { } else {
// For new cards, update the count immediately // For new cards, update the count immediately
const columnId = formData.get('column_id'); const columnId = formData.get('column_id');
updateColumnCardCountAfterAdd(columnId); updateColumnCardCountAfterAdd(columnId);
loadKanbanBoard(currentBoard.id); // Still reload for the new card content if (currentBoard && currentBoard.id) {
console.log('Reloading board after new card:', currentBoard.id);
loadKanbanBoard(currentBoard.id);
} else {
// Fallback: reload the currently selected board
const boardSelect = document.getElementById('board-select');
if (boardSelect && boardSelect.value) {
console.log('Fallback: reloading selected board after new card:', boardSelect.value);
loadKanbanBoard(boardSelect.value);
}
}
} }
} else { } else {
alert('Error: ' + data.message); alert('Error: ' + data.message);
} }
}); });
}); });
}
// Column form submission // Column form submission
const columnForm = document.getElementById('column-form'); const columnForm = document.getElementById('column-form');
@@ -997,17 +1021,26 @@ function setupModals() {
// Close buttons // Close buttons
document.querySelectorAll('.close').forEach(btn => { document.querySelectorAll('.close').forEach(btn => {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
this.closest('.modal').style.display = 'none'; const modal = this.closest('.modal');
if (modal) {
modal.style.display = 'none';
}
}); });
}); });
document.getElementById('cancel-board').addEventListener('click', () => { const cancelBoardBtn = document.getElementById('cancel-board');
if (cancelBoardBtn) {
cancelBoardBtn.addEventListener('click', () => {
boardModal.style.display = 'none'; boardModal.style.display = 'none';
}); });
}
document.getElementById('cancel-card').addEventListener('click', () => { const cancelCardBtn = document.getElementById('cancel-card');
if (cancelCardBtn) {
cancelCardBtn.addEventListener('click', () => {
cardModal.style.display = 'none'; cardModal.style.display = 'none';
}); });
}
// Close modals when clicking outside // Close modals when clicking outside
const columnModal = document.getElementById('column-modal'); const columnModal = document.getElementById('column-modal');