diff --git a/app.py b/app.py index d48e3cd..9bfb8de 100644 --- a/app.py +++ b/app.py @@ -1039,12 +1039,249 @@ def delete_user(user_id): return redirect(url_for('admin_users')) username = user.username - db.session.delete(user) - db.session.commit() - - flash(f'User {username} deleted successfully', 'success') + + 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.commit() + + 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')) +@app.route('/confirm-company-deletion/', 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']) @login_required def profile(): @@ -1870,15 +2107,62 @@ def system_admin_delete_user(user_id): username = user.username company_name = user.company.name if user.company else 'Unknown' - # Delete related data first - TimeEntry.query.filter_by(user_id=user.id).delete() - WorkConfig.query.filter_by(user_id=user.id).delete() - - # Delete the user - db.session.delete(user) - db.session.commit() - - flash(f'User "{username}" from company "{company_name}" has been deleted.', 'success') + try: + # Handle dependent records before deleting user + # 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() + + 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.commit() + + 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')) @app.route('/system-admin/companies') @@ -3352,8 +3636,8 @@ def project_kanban(project_id): 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 all Kanban boards for this company (unified boards) + 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 if project.team_id: @@ -3364,18 +3648,98 @@ def project_kanban(project_id): # Get tasks for task assignment dropdown 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', title=f'Kanban - {project.name}', project=project, boards=boards, team_members=team_members, - tasks=tasks) + tasks=tasks, + available_projects=available_projects) @app.route('/kanban') @role_required(Role.TEAM_MEMBER) @company_required 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]: # 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() @@ -3394,20 +3758,17 @@ def kanban_overview(): 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 + # Get all company Kanban boards (unified boards) + boards = KanbanBoard.query.filter_by( + company_id=g.user.company_id, + is_active=True + ).order_by(KanbanBoard.created_at.desc()).all() return render_template('kanban_overview.html', title='Kanban Overview', - project_boards=project_boards) + boards=boards, + projects=projects, + create_board=create_board) # Task API Routes @app.route('/api/tasks', methods=['POST']) @@ -3840,26 +4201,21 @@ def get_kanban_stats(): is_active=True ).all() - # Count boards and cards - total_boards = 0 + # Count company boards and cards + boards = KanbanBoard.query.filter_by(company_id=g.user.company_id, is_active=True).all() + total_boards = len(boards) 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]) + + 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 + 'total_cards': total_cards, + 'total_projects': len(projects) } }) @@ -3871,14 +4227,21 @@ def get_kanban_stats(): @company_required def get_kanban_boards(): try: + # Optional project_id filter for backward compatibility 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() + + # 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() + if not project or not project.is_user_allowed(g.user): + 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 = boards_query.all() boards_data = [] for board in boards: @@ -3902,27 +4265,30 @@ def get_kanban_boards(): 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'}) + + # 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() + 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() + # Check if board name already exists in company + existing = KanbanBoard.query.filter_by(company_id=g.user.company_id, name=name).first() 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 board = KanbanBoard( name=name, description=data.get('description', ''), - project_id=project_id, + company_id=g.user.company_id, is_default=data.get('is_default') in ['true', 'on', True], created_by_id=g.user.id ) @@ -3959,12 +4325,12 @@ def create_kanban_board(): @company_required def get_kanban_board(board_id): try: - board = KanbanBoard.query.join(Project).filter( + board = KanbanBoard.query.filter( KanbanBoard.id == board_id, - Project.company_id == g.user.company_id + KanbanBoard.company_id == g.user.company_id ).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'}) columns_data = [] @@ -3987,6 +4353,11 @@ def get_kanban_board(board_id): 'id': card.assigned_to.id, 'username': card.assigned_to.username } 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_name': card.task.name if card.task 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, 'name': board.name, 'description': board.description, - 'project': { - 'id': board.project.id, - 'name': board.project.name, - 'code': board.project.code + 'company': { + 'id': board.company.id, + 'name': board.company.name }, 'columns': columns_data } @@ -4031,12 +4401,12 @@ def create_kanban_card(): column_id = data.get('column_id') # Verify column access - column = KanbanColumn.query.join(KanbanBoard).join(Project).filter( + column = KanbanColumn.query.join(KanbanBoard).filter( KanbanColumn.id == column_id, - Project.company_id == g.user.company_id + KanbanBoard.company_id == g.user.company_id ).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'}) title = data.get('title') @@ -4053,6 +4423,14 @@ def create_kanban_card(): if data.get('due_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 card = KanbanCard( title=title, @@ -4060,6 +4438,7 @@ def create_kanban_card(): position=max_position + 1, color=data.get('color'), column_id=column_id, + project_id=project_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, @@ -4085,21 +4464,21 @@ def move_kanban_card(card_id): new_position = data.get('position') # 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, - Project.company_id == g.user.company_id + KanbanBoard.company_id == g.user.company_id ).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'}) # 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, - Project.company_id == g.user.company_id + KanbanBoard.company_id == g.user.company_id ).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'}) old_column_id = card.column_id @@ -4146,12 +4525,12 @@ def move_kanban_card(card_id): @company_required def update_kanban_card(card_id): try: - card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).join(Project).filter( + card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).filter( KanbanCard.id == card_id, - Project.company_id == g.user.company_id + KanbanBoard.company_id == g.user.company_id ).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'}) 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 if 'due_date' in data: 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() db.session.commit() @@ -4184,12 +4573,12 @@ def update_kanban_card(card_id): @company_required def delete_kanban_card(card_id): try: - card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).join(Project).filter( + card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).filter( KanbanCard.id == card_id, - Project.company_id == g.user.company_id + KanbanBoard.company_id == g.user.company_id ).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'}) column_id = card.column_id @@ -4227,12 +4616,12 @@ def create_kanban_column(): board_id = int(data.get('board_id')) # Verify board access - board = KanbanBoard.query.join(Project).filter( + board = KanbanBoard.query.filter( KanbanBoard.id == board_id, - Project.company_id == g.user.company_id + KanbanBoard.company_id == g.user.company_id ).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'}) name = data.get('name') @@ -4273,12 +4662,12 @@ def create_kanban_column(): @company_required def update_kanban_column(column_id): try: - column = KanbanColumn.query.join(KanbanBoard).join(Project).filter( + column = KanbanColumn.query.join(KanbanBoard).filter( KanbanColumn.id == column_id, - Project.company_id == g.user.company_id + KanbanBoard.company_id == g.user.company_id ).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'}) data = request.get_json() @@ -4321,12 +4710,12 @@ def move_kanban_column(column_id): new_position = int(data.get('position')) # Verify column access - column = KanbanColumn.query.join(KanbanBoard).join(Project).filter( + column = KanbanColumn.query.join(KanbanBoard).filter( KanbanColumn.id == column_id, - Project.company_id == g.user.company_id + KanbanBoard.company_id == g.user.company_id ).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'}) old_position = column.position @@ -4378,12 +4767,12 @@ def move_kanban_column(column_id): @company_required def delete_kanban_column(column_id): try: - column = KanbanColumn.query.join(KanbanBoard).join(Project).filter( + column = KanbanColumn.query.join(KanbanBoard).filter( KanbanColumn.id == column_id, - Project.company_id == g.user.company_id + KanbanBoard.company_id == g.user.company_id ).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'}) # Check if column has active cards @@ -4872,18 +5261,11 @@ def get_widget_data(widget_id): } for t in tasks] elif widget.widget_type == WidgetType.KANBAN_SUMMARY: - # Get kanban data summary - if g.user.team_id: - project_ids = [p.id for p in Project.query.filter( - Project.company_id == g.user.company_id, - 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() - else: - boards = [] + # Get kanban data summary - company-wide boards + boards = KanbanBoard.query.filter_by( + company_id=g.user.company_id, + is_active=True + ).limit(3).all() board_summaries = [] for board in boards: @@ -4893,7 +5275,7 @@ def get_widget_data(widget_id): board_summaries.append({ 'id': board.id, 'name': board.name, - 'project_name': board.project.name, + 'company_name': board.company.name, 'total_cards': total_cards, 'columns': len(columns) }) diff --git a/models.py b/models.py index be22a12..99a3a5d 100644 --- a/models.py +++ b/models.py @@ -729,12 +729,12 @@ class KanbanBoard(db.Model): 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) + # Company association for multi-tenancy (removed project-specific constraint) + company_id = db.Column(db.Integer, db.ForeignKey('company.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 + is_default = db.Column(db.Boolean, default=False) # Default board for company # Metadata 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) # 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]) 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'),) + # Unique constraint per company + __table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_kanban_board_name_per_company'),) def __repr__(self): return f'' @@ -804,6 +804,9 @@ class KanbanCard(db.Model): # Column association 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 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) # Relationships + project = db.relationship('Project', backref='kanban_cards') 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]) @@ -829,8 +833,26 @@ class KanbanCard(db.Model): 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) + # Check company membership first + 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 class WidgetType(enum.Enum): diff --git a/templates/confirm_company_deletion.html b/templates/confirm_company_deletion.html new file mode 100644 index 0000000..f4eb3d6 --- /dev/null +++ b/templates/confirm_company_deletion.html @@ -0,0 +1,269 @@ +{% extends "layout.html" %} + +{% block content %} +
+
+

⚠️ Confirm Company Deletion

+

Critical Action Required - Review All Data Before Proceeding

+ ← Back to User Management +
+ +
+

Critical Warning!

+

You are about to delete user {{ user.username }} who is the last administrator/supervisor in company {{ company.name }}.

+

This action will permanently delete the entire company and ALL associated data.

+

This action cannot be undone!

+
+ +
+

The following data will be permanently deleted:

+ + +
+

🏢 Company Information

+ + + + + + + + + + + + + + + + + +
Company Name:{{ company.name }}
Company Slug:{{ company.slug }}
Created:{{ company.created_at.strftime('%Y-%m-%d %H:%M') }}
Description:{{ company.description or 'None' }}
+
+ + + {% if users %} +
+

👥 Users ({{ users|length }})

+ + + + + + + + + + + + + {% for u in users %} + + + + + + + + + {% endfor %} + +
UsernameEmailRoleTeamJoinedStatus
+ {{ u.username }} + {% if u.id == user.id %}Target User{% endif %} + {{ u.email }}{{ u.role.value }}{{ u.team.name if u.team else 'None' }}{{ u.created_at.strftime('%Y-%m-%d') }} + + {% if u.is_blocked %}Blocked{% else %}Active{% endif %} + +
+
+ {% endif %} + + + {% if teams %} +
+

🏭 Teams ({{ teams|length }})

+ + + + + + + + + + + {% for team in teams %} + + + + + + + {% endfor %} + +
Team NameDescriptionMembersCreated
{{ team.name }}{{ team.description or 'None' }}{{ team.users|length }}{{ team.created_at.strftime('%Y-%m-%d') }}
+
+ {% endif %} + + + {% if projects %} +
+

📝 Projects ({{ projects|length }})

+ + + + + + + + + + + + + {% for project in projects %} + + + + + + + + + {% endfor %} + +
Project CodeProject NameTeamTasksTime EntriesCreated By
{{ project.code }}{{ project.name }}{{ project.team.name if project.team else 'None' }}{{ project.tasks|length }}{{ project.time_entries|length }}{{ project.created_by.username }}
+
+ {% endif %} + + + {% if tasks %} +
+

✅ Tasks ({{ tasks|length }})

+ + + + + + + + + + + + + {% for task in tasks %} + + + + + + + + + {% endfor %} + +
Task NameProjectStatusPriorityAssigned ToSubtasks
{{ task.name }}{{ task.project.code }}{{ task.status.value }}{{ task.priority.value }}{{ task.assigned_to.username if task.assigned_to else 'None' }}{{ task.subtasks|length }}
+
+ {% endif %} + + + {% if kanban_boards %} +
+

📋 Kanban Boards ({{ kanban_boards|length }})

+ + + + + + + + + + + {% for board in kanban_boards %} + + + + + + + {% endfor %} + +
Board NameColumnsCardsCreated By
{{ board.name }}{{ board.columns|length }}{{ board.columns|map(attribute='cards')|map('length')|sum }}{{ board.created_by.username }}
+
+ {% endif %} + + + {% if time_entries_count > 0 %} +
+

⏱️ Time Entries ({{ time_entries_count }})

+
+

{{ time_entries_count }} time tracking entries will be permanently deleted.

+

Total Hours Tracked: {{ total_hours_tracked }} hours

+
+
+ {% endif %} + + + {% if categories %} +
+

🏷️ Project Categories ({{ categories|length }})

+ + + + + + + + + + {% for category in categories %} + + + + + + {% endfor %} + +
Category NameProjectsCreated By
{{ category.name }}{{ category.projects|length }}{{ category.created_by.username }}
+
+ {% endif %} +
+ + +
+
+

Final Confirmation Required

+
+ +
+
+ + +
+ +
+ +
+ +
+ Cancel + +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/kanban_overview.html b/templates/kanban_overview.html index 3673be6..8fdfcb7 100644 --- a/templates/kanban_overview.html +++ b/templates/kanban_overview.html @@ -3,76 +3,98 @@ {% block content %}
-

Kanban Board Overview

-

Manage your tasks visually across all projects

+

Unified Kanban Boards

+

Organize tasks from any project on shared company-wide boards

- {% if project_boards %} -
- {% for project, boards in project_boards.items() %} -
-
-
-

- {{ project.code }} - {{ project.name }} + {% if create_board %} + +
+
+

Create New Kanban Board

+ +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+ {% endif %} + + {% if boards %} +
+ {% for board in boards %} +
+
+
+

+ {{ board.name }} + {% if board.is_default %} + Default + {% endif %}

- {% if project.category %} - - {{ project.category.icon or '📁' }} {{ project.category.name }} - - {% endif %} - {% if project.description %} -

{{ project.description }}

+ {% if board.description %} +

{{ board.description }}

{% endif %}
-
- Open Kanban - Task List +
+ Open Board + {% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %} + + {% endif %}
-
-

Kanban Boards ({{ boards|length }})

-
- {% for board in boards %} -
-
-
- {{ board.name }} - {% if board.is_default %} - Default - {% endif %} -
- {% if board.description %} -
{{ board.description }}
- {% endif %} -
-
- {{ board.columns|length }} columns - - {% 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 - -
+
+
+
+ {{ board.columns|length }} + Columns +
+
+ {% 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 +
+
+ {% 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 %} + {{ project_contexts|length }} + Projects
- {% endfor %}
- {% 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 %} -
{{ default_board.name }} Preview
+ {% if board.columns %} +
Board Preview
- {% for column in default_board.columns %} + {% for column in board.columns %} {% if loop.index <= 4 %}
@@ -85,8 +107,11 @@ {% if loop.index <= 3 %}
- {% if card.title|length > 30 %} - {{ card.title|truncate(30, True) }} + {% if card.project %} + [{{ card.project.code }}] + {% endif %} + {% if card.title|length > 25 %} + {{ card.title|truncate(25, True) }} {% else %} {{ card.title }} {% endif %} @@ -104,9 +129,9 @@
{% endif %} {% endfor %} - {% if default_board.columns|length > 4 %} + {% if board.columns|length > 4 %}
- +{{ default_board.columns|length - 4 }} more columns + +{{ board.columns|length - 4 }} more columns
{% endif %}
@@ -119,29 +144,33 @@
-

{{ project_boards.keys()|list|length }}

-

Projects with Kanban

-
-
- {% set total_boards = 0 %} - {% for project, boards in project_boards.items() %} - {% set total_boards = total_boards + boards|length %} - {% endfor %} -

{{ total_boards }}

-

Total Boards

+

{{ boards|length }}

+

Company Boards

{% 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 %} + {% for board in boards %} + {% for column in board.columns %} + {% set total_cards = total_cards + column.cards|selectattr('is_active')|list|length %} {% endfor %} {% endfor %}

{{ total_cards }}

Total Cards

+
+ {% 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 %} +

{{ total_projects|length }}

+

Active Projects

+
{% else %} @@ -150,17 +179,17 @@
📋

No Kanban Boards Yet

-

Start organizing your projects with visual Kanban boards.

+

Create unified boards to organize tasks from any project.

Getting Started:

    -
  1. Go to a project from Project Management
  2. -
  3. Click the "Kanban" button
  4. -
  5. Create your first board and start organizing tasks
  6. +
  7. Click the "Create Board" button
  8. +
  9. Set up columns for your workflow (To Do, In Progress, Done)
  10. +
  11. Add cards from any project to organize your work
{% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %} - Go to Projects + {% endif %}
@@ -187,13 +216,13 @@ margin: 0; } -.projects-grid { +.boards-grid { display: grid; gap: 2rem; margin-bottom: 2rem; } -.project-card { +.board-card { background: white; border: 1px solid #dee2e6; border-radius: 12px; @@ -202,11 +231,11 @@ transition: box-shadow 0.2s ease; } -.project-card:hover { +.board-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.15); } -.project-header { +.board-header { padding: 1.5rem; background: #f8f9fa; border-bottom: 1px solid #dee2e6; @@ -215,43 +244,65 @@ align-items: flex-start; } -.project-info { +.board-info { flex: 1; } -.project-name { +.board-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-size: 1.2rem; font-weight: 600; + color: #333; } -.project-description { +.board-description { color: #666; margin: 0.5rem 0 0 0; font-size: 0.9rem; } -.project-actions { +.board-actions { display: flex; gap: 0.5rem; align-items: flex-start; } -.boards-section { +.board-stats-section { padding: 1.5rem; 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 { margin: 0 0 1rem 0; color: #333; @@ -380,6 +431,16 @@ 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 { color: #666; font-size: 0.7rem; @@ -480,6 +541,76 @@ 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 { padding: 0.25rem 0.5rem; border-radius: 4px; @@ -488,12 +619,12 @@ } @media (max-width: 768px) { - .project-header { + .board-header { flex-direction: column; gap: 1rem; } - .project-actions { + .board-actions { width: 100%; justify-content: flex-start; } @@ -505,13 +636,77 @@ .quick-stats { grid-template-columns: 1fr; } + + .stat-grid { + grid-template-columns: repeat(3, 1fr); + } } {% endblock %} \ No newline at end of file diff --git a/templates/project_kanban.html b/templates/project_kanban.html index 9cbf480..51dfb44 100644 --- a/templates/project_kanban.html +++ b/templates/project_kanban.html @@ -4,12 +4,19 @@
-

Kanban Board: {{ project.code }} - {{ project.name }}

-

{{ project.description or 'No description available' }}

- {% if project.category %} - - {{ project.category.icon or '📁' }} {{ project.category.name }} - +

Unified Kanban Board

+

Organize tasks from any project on shared boards

+ {% if project %} +
+ Project Context: + {{ project.code }} + {{ project.name }} + {% if project.category %} + + {{ project.category.icon or '📁' }} {{ project.category.name }} + + {% endif %} +
{% endif %}
@@ -17,8 +24,12 @@ {% endif %} + {% if project %} Back to Projects Task View + {% else %} + Back to Kanban Overview + {% endif %}
@@ -29,7 +40,7 @@ + {% if project %} + + {% endif %}
@@ -79,7 +92,7 @@
@@ -122,22 +135,38 @@
- - + + {% for project_option in available_projects %} + {% endfor %}
+
+ + +
+
+
+
@@ -231,6 +260,36 @@ 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 { display: flex; gap: 0.75rem; @@ -238,91 +297,7 @@ flex-wrap: wrap; } -/* Consistent button sizing */ -.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; -} +/* Button styles now centralized in main style.css */ .board-selection { margin-bottom: 2rem; @@ -438,6 +413,14 @@ font-size: 0.8rem; } +.card-project { + background: #f3e8ff; + color: #7c3aed; + padding: 0.2rem 0.4rem; + border-radius: 4px; + font-weight: 600; +} + .card-assignee { background: #e7f3ff; color: #0066cc; @@ -716,11 +699,12 @@