feat(company): add team management for company MANAGERs
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Allow company MANAGERs to add/remove users, change roles, and manage granular permissions from the company edit page. New "Zespół" tab with AJAX-based team CRUD. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c5efd4056c
commit
49555f6a2e
@ -15,3 +15,4 @@ from . import routes_pej # noqa: E402, F401
|
||||
from . import routes_announcements # noqa: E402, F401
|
||||
from . import routes_company_edit # noqa: E402, F401
|
||||
from . import routes_rss # noqa: E402, F401
|
||||
from . import routes_team # noqa: E402, F401
|
||||
|
||||
379
blueprints/public/routes_team.py
Normal file
379
blueprints/public/routes_team.py
Normal file
@ -0,0 +1,379 @@
|
||||
"""
|
||||
Team Management Routes
|
||||
======================
|
||||
|
||||
Routes for company MANAGERs to manage their team members:
|
||||
add/remove users, change roles, toggle permissions.
|
||||
"""
|
||||
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from blueprints.public import bp
|
||||
from database import (
|
||||
SessionLocal, User, Company, UserCompany,
|
||||
UserCompanyPermissions, SystemRole, CompanyRole,
|
||||
)
|
||||
from email_service import send_welcome_activation_email
|
||||
from extensions import limiter
|
||||
from utils.permissions import can_invite_user_to_company
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
||||
|
||||
PERMISSION_KEYS = [
|
||||
'can_edit_description', 'can_edit_services', 'can_edit_contacts',
|
||||
'can_edit_social', 'can_manage_classifieds', 'can_post_forum',
|
||||
'can_view_analytics',
|
||||
]
|
||||
|
||||
|
||||
def _require_team_manager(company_id):
|
||||
"""Check if current user can manage this company's team. Returns (db, company) or raises."""
|
||||
if not can_invite_user_to_company(current_user, company_id):
|
||||
return None, None
|
||||
db = SessionLocal()
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
db.close()
|
||||
return None, None
|
||||
return db, company
|
||||
|
||||
|
||||
@bp.route('/firma/<int:company_id>/zespol')
|
||||
@login_required
|
||||
def team_list(company_id):
|
||||
"""List team members with roles and permissions."""
|
||||
db, company = _require_team_manager(company_id)
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
try:
|
||||
members = db.query(UserCompany).filter(
|
||||
UserCompany.company_id == company_id
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for uc in members:
|
||||
user = db.query(User).filter(User.id == uc.user_id).first()
|
||||
if not user:
|
||||
continue
|
||||
|
||||
perms = db.query(UserCompanyPermissions).filter_by(
|
||||
user_id=user.id, company_id=company_id
|
||||
).first()
|
||||
|
||||
member_data = {
|
||||
'id': user.id,
|
||||
'name': user.name or user.email,
|
||||
'email': user.email,
|
||||
'phone': user.phone,
|
||||
'role': uc.role,
|
||||
'is_primary': uc.is_primary,
|
||||
'is_active': user.is_active,
|
||||
'is_current_user': user.id == current_user.id,
|
||||
'last_login': user.last_login.isoformat() if user.last_login else None,
|
||||
'permissions': {},
|
||||
}
|
||||
|
||||
if perms:
|
||||
for key in PERMISSION_KEYS:
|
||||
member_data['permissions'][key] = getattr(perms, key, False)
|
||||
|
||||
result.append(member_data)
|
||||
|
||||
# Sort: MANAGERs first, then by name
|
||||
role_order = {'MANAGER': 0, 'EMPLOYEE': 1, 'VIEWER': 2, 'NONE': 3}
|
||||
result.sort(key=lambda m: (role_order.get(m['role'], 9), (m['name'] or '').lower()))
|
||||
|
||||
return jsonify({'success': True, 'members': result, 'company_name': company.name})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing team for company {company_id}: {e}")
|
||||
return jsonify({'success': False, 'error': 'Błąd podczas pobierania zespołu'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/firma/<int:company_id>/zespol/dodaj', methods=['POST'])
|
||||
@login_required
|
||||
@limiter.limit("20 per hour")
|
||||
def team_add_member(company_id):
|
||||
"""Add a user to the company team."""
|
||||
db, company = _require_team_manager(company_id)
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
email = (data.get('email') or '').strip().lower()
|
||||
name = (data.get('name') or '').strip()
|
||||
role_str = (data.get('role') or 'EMPLOYEE').upper()
|
||||
|
||||
# Validate
|
||||
if not email or not EMAIL_RE.match(email):
|
||||
return jsonify({'success': False, 'error': 'Podaj prawidłowy adres email'}), 400
|
||||
if not name:
|
||||
return jsonify({'success': False, 'error': 'Podaj imię i nazwisko'}), 400
|
||||
if role_str not in ('VIEWER', 'EMPLOYEE', 'MANAGER'):
|
||||
return jsonify({'success': False, 'error': 'Nieprawidłowa rola'}), 400
|
||||
|
||||
# Check if already in this team
|
||||
existing_user = db.query(User).filter(User.email == email).first()
|
||||
if existing_user:
|
||||
existing_uc = db.query(UserCompany).filter_by(
|
||||
user_id=existing_user.id, company_id=company_id
|
||||
).first()
|
||||
if existing_uc:
|
||||
return jsonify({'success': False, 'error': 'Ta osoba już należy do Twojego zespołu'}), 400
|
||||
|
||||
is_new_account = False
|
||||
|
||||
if existing_user:
|
||||
# Existing user — just link to company
|
||||
user = existing_user
|
||||
is_primary = user.company_id is None # primary if no company yet
|
||||
uc = UserCompany(
|
||||
user_id=user.id,
|
||||
company_id=company_id,
|
||||
role=role_str,
|
||||
is_primary=is_primary,
|
||||
)
|
||||
db.add(uc)
|
||||
|
||||
if is_primary:
|
||||
user.company_id = company_id
|
||||
user.company_role = role_str
|
||||
user.is_norda_member = company.status == 'active'
|
||||
|
||||
else:
|
||||
# New user — create account
|
||||
is_new_account = True
|
||||
password_chars = string.ascii_letters + string.digits + "!@#$%^&*"
|
||||
generated_password = ''.join(secrets.choice(password_chars) for _ in range(16))
|
||||
password_hash = generate_password_hash(generated_password, method='pbkdf2:sha256')
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
password_hash=password_hash,
|
||||
name=name,
|
||||
company_id=company_id,
|
||||
is_verified=True,
|
||||
is_active=True,
|
||||
is_norda_member=company.status == 'active',
|
||||
)
|
||||
user.set_role(SystemRole.EMPLOYEE)
|
||||
user.set_company_role(CompanyRole[role_str])
|
||||
db.add(user)
|
||||
db.flush() # get user.id
|
||||
|
||||
uc = UserCompany(
|
||||
user_id=user.id,
|
||||
company_id=company_id,
|
||||
role=role_str,
|
||||
is_primary=True,
|
||||
)
|
||||
db.add(uc)
|
||||
|
||||
# Create permissions
|
||||
db.flush()
|
||||
UserCompanyPermissions.get_or_create(db, user.id, company_id)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Send activation email for new accounts
|
||||
if is_new_account:
|
||||
try:
|
||||
token = secrets.token_urlsafe(32)
|
||||
user.reset_token = token
|
||||
user.reset_token_expires = datetime.now() + timedelta(hours=72)
|
||||
db.commit()
|
||||
|
||||
app_url = os.getenv('APP_URL', 'https://nordabiznes.pl')
|
||||
reset_url = f"{app_url}/reset-password/{token}"
|
||||
send_welcome_activation_email(email, name, reset_url)
|
||||
logger.info(f"Activation email sent to {email} for company {company_id}")
|
||||
except Exception as mail_err:
|
||||
logger.error(f"Failed to send activation email to {email}: {mail_err}")
|
||||
|
||||
logger.info(
|
||||
f"Manager {current_user.email} added {email} (role={role_str}) "
|
||||
f"to company {company_id} (new_account={is_new_account})"
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user_id': user.id,
|
||||
'is_new_account': is_new_account,
|
||||
'message': f'{"Konto utworzone i email" if is_new_account else "Użytkownik"} '
|
||||
f'został dodany do zespołu',
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error adding team member to company {company_id}: {e}")
|
||||
return jsonify({'success': False, 'error': 'Błąd podczas dodawania osoby'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/firma/<int:company_id>/zespol/<int:user_id>/rola', methods=['POST'])
|
||||
@login_required
|
||||
def team_change_role(company_id, user_id):
|
||||
"""Change a team member's company role."""
|
||||
if user_id == current_user.id:
|
||||
return jsonify({'success': False, 'error': 'Nie możesz zmienić własnej roli'}), 400
|
||||
|
||||
db, company = _require_team_manager(company_id)
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
role_str = (data.get('role') or '').upper()
|
||||
if role_str not in ('VIEWER', 'EMPLOYEE', 'MANAGER'):
|
||||
return jsonify({'success': False, 'error': 'Nieprawidłowa rola'}), 400
|
||||
|
||||
uc = db.query(UserCompany).filter_by(
|
||||
user_id=user_id, company_id=company_id
|
||||
).first()
|
||||
if not uc:
|
||||
return jsonify({'success': False, 'error': 'Użytkownik nie należy do tego zespołu'}), 404
|
||||
|
||||
uc.role = role_str
|
||||
|
||||
# Sync user.company_role if this is their primary company
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user and user.company_id == company_id:
|
||||
user.set_company_role(CompanyRole[role_str])
|
||||
|
||||
# Update permissions when role changes
|
||||
perms = UserCompanyPermissions.get_or_create(db, user_id, company_id)
|
||||
if role_str == 'MANAGER':
|
||||
perms.can_edit_contacts = True
|
||||
perms.can_edit_social = True
|
||||
perms.can_view_analytics = True
|
||||
elif role_str in ('EMPLOYEE', 'VIEWER'):
|
||||
perms.can_edit_contacts = False
|
||||
perms.can_edit_social = False
|
||||
perms.can_view_analytics = False
|
||||
perms.granted_by_id = current_user.id
|
||||
perms.updated_at = datetime.now()
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Manager {current_user.email} changed role of user {user_id} to {role_str} in company {company_id}")
|
||||
return jsonify({'success': True, 'message': f'Rola zmieniona na {role_str}'})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error changing role for user {user_id} in company {company_id}: {e}")
|
||||
return jsonify({'success': False, 'error': 'Błąd podczas zmiany roli'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/firma/<int:company_id>/zespol/<int:user_id>/uprawnienia', methods=['POST'])
|
||||
@login_required
|
||||
def team_toggle_permission(company_id, user_id):
|
||||
"""Toggle a specific permission for a team member."""
|
||||
db, company = _require_team_manager(company_id)
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
perm_key = data.get('permission')
|
||||
perm_value = bool(data.get('value'))
|
||||
|
||||
if perm_key not in PERMISSION_KEYS:
|
||||
return jsonify({'success': False, 'error': 'Nieprawidłowe uprawnienie'}), 400
|
||||
|
||||
# Only toggle permissions for EMPLOYEE role
|
||||
uc = db.query(UserCompany).filter_by(
|
||||
user_id=user_id, company_id=company_id
|
||||
).first()
|
||||
if not uc:
|
||||
return jsonify({'success': False, 'error': 'Użytkownik nie należy do tego zespołu'}), 404
|
||||
if uc.role != 'EMPLOYEE':
|
||||
return jsonify({'success': False, 'error': 'Uprawnienia można zmieniać tylko pracownikom'}), 400
|
||||
|
||||
perms = UserCompanyPermissions.get_or_create(db, user_id, company_id)
|
||||
setattr(perms, perm_key, perm_value)
|
||||
perms.granted_by_id = current_user.id
|
||||
perms.updated_at = datetime.now()
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Manager {current_user.email} set {perm_key}={perm_value} for user {user_id} in company {company_id}")
|
||||
return jsonify({'success': True, 'message': 'Uprawnienie zaktualizowane'})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error toggling permission for user {user_id}: {e}")
|
||||
return jsonify({'success': False, 'error': 'Błąd podczas zmiany uprawnienia'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/firma/<int:company_id>/zespol/<int:user_id>/usun', methods=['POST'])
|
||||
@login_required
|
||||
def team_remove_member(company_id, user_id):
|
||||
"""Remove a user from the company team (does not delete the account)."""
|
||||
if user_id == current_user.id:
|
||||
return jsonify({'success': False, 'error': 'Nie możesz usunąć siebie z zespołu'}), 400
|
||||
|
||||
db, company = _require_team_manager(company_id)
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
try:
|
||||
uc = db.query(UserCompany).filter_by(
|
||||
user_id=user_id, company_id=company_id
|
||||
).first()
|
||||
if not uc:
|
||||
return jsonify({'success': False, 'error': 'Użytkownik nie należy do tego zespołu'}), 404
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
user_name = (user.name or user.email) if user else '?'
|
||||
|
||||
# Delete permissions
|
||||
db.query(UserCompanyPermissions).filter_by(
|
||||
user_id=user_id, company_id=company_id
|
||||
).delete()
|
||||
|
||||
# Delete company association
|
||||
db.delete(uc)
|
||||
|
||||
# Clear primary company link if this was user's primary
|
||||
if user and user.company_id == company_id:
|
||||
user.company_id = None
|
||||
user.set_company_role(CompanyRole.NONE)
|
||||
# Check if user has other company associations
|
||||
other_uc = db.query(UserCompany).filter(
|
||||
UserCompany.user_id == user_id,
|
||||
UserCompany.company_id != company_id
|
||||
).first()
|
||||
if not other_uc:
|
||||
user.is_norda_member = False
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Manager {current_user.email} removed user {user_id} ({user_name}) from company {company_id}")
|
||||
return jsonify({'success': True, 'message': f'{user_name} został usunięty z zespołu'})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error removing user {user_id} from company {company_id}: {e}")
|
||||
return jsonify({'success': False, 'error': 'Błąd podczas usuwania osoby'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
@ -292,6 +292,44 @@
|
||||
.ce-tab[data-tab="social"].active svg { color: #ec4899; }
|
||||
.ce-tab[data-tab="visibility"].active { color: #8b5cf6; border-bottom-color: #8b5cf6; }
|
||||
.ce-tab[data-tab="visibility"].active svg { color: #8b5cf6; }
|
||||
.ce-tab[data-tab="team"].active { color: #0ea5e9; border-bottom-color: #0ea5e9; }
|
||||
.ce-tab[data-tab="team"].active svg { color: #0ea5e9; }
|
||||
|
||||
/* Team management */
|
||||
.team-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-lg); flex-wrap: wrap; gap: var(--spacing-sm); }
|
||||
.team-header h3 { margin: 0; font-size: var(--font-size-lg); color: var(--text-primary); }
|
||||
.team-add-form { background: var(--surface-alt, #f8fafc); border-radius: var(--radius); padding: var(--spacing-lg); margin-bottom: var(--spacing-lg); border: 1px solid var(--border-light, #e2e8f0); }
|
||||
.team-add-form .form-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: var(--spacing-md); align-items: end; }
|
||||
.team-add-form .form-row-bottom { display: grid; grid-template-columns: 1fr auto; gap: var(--spacing-md); align-items: end; margin-top: var(--spacing-md); }
|
||||
.team-add-form label { display: block; font-size: var(--font-size-sm); font-weight: 500; color: var(--text-secondary); margin-bottom: 4px; }
|
||||
.team-member-card { display: flex; align-items: center; gap: var(--spacing-md); padding: var(--spacing-md) var(--spacing-lg); border-bottom: 1px solid var(--border-light, #e2e8f0); flex-wrap: wrap; }
|
||||
.team-member-card:last-child { border-bottom: none; }
|
||||
.team-member-avatar { width: 42px; height: 42px; border-radius: 50%; background: var(--primary, #2E4872); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 15px; flex-shrink: 0; }
|
||||
.team-member-info { flex: 1; min-width: 150px; }
|
||||
.team-member-name { font-weight: 600; color: var(--text-primary); }
|
||||
.team-member-email { font-size: var(--font-size-sm); color: var(--text-secondary); }
|
||||
.team-member-actions { display: flex; align-items: center; gap: var(--spacing-sm); flex-wrap: wrap; }
|
||||
.team-role-select { padding: 6px 10px; border-radius: var(--radius); border: 1px solid var(--border-light, #e2e8f0); font-size: var(--font-size-sm); background: var(--surface, #fff); }
|
||||
.team-role-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||||
.team-role-badge.manager { background: #dbeafe; color: #1e40af; }
|
||||
.team-role-badge.employee { background: #dcfce7; color: #166534; }
|
||||
.team-role-badge.viewer { background: #f3e8ff; color: #6b21a8; }
|
||||
.team-btn-remove { padding: 6px 12px; border-radius: var(--radius); border: 1px solid #fecaca; background: #fff; color: #dc2626; font-size: var(--font-size-sm); cursor: pointer; transition: all 0.2s; }
|
||||
.team-btn-remove:hover { background: #fef2f2; border-color: #dc2626; }
|
||||
.team-perms-toggle { margin-top: var(--spacing-sm); padding: var(--spacing-sm) var(--spacing-md); background: var(--surface-alt, #f8fafc); border-radius: var(--radius); }
|
||||
.team-perms-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--spacing-xs); }
|
||||
.team-perm-item { display: flex; align-items: center; gap: 8px; font-size: var(--font-size-sm); padding: 4px 0; }
|
||||
.team-perm-item input[type="checkbox"] { accent-color: var(--primary, #2E4872); width: 16px; height: 16px; }
|
||||
.team-empty { text-align: center; padding: var(--spacing-xl); color: var(--text-secondary); }
|
||||
.team-loading { text-align: center; padding: var(--spacing-xl); color: var(--text-secondary); }
|
||||
.team-you-badge { font-size: 11px; background: #e0f2fe; color: #0369a1; padding: 1px 6px; border-radius: 8px; margin-left: 6px; }
|
||||
@media (max-width: 768px) {
|
||||
.team-add-form .form-row { grid-template-columns: 1fr; }
|
||||
.team-add-form .form-row-bottom { grid-template-columns: 1fr; }
|
||||
.team-member-card { padding: var(--spacing-md); }
|
||||
.team-member-actions { width: 100%; justify-content: flex-end; }
|
||||
.team-perms-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Tab content */
|
||||
.ce-tab-content { display: none; padding: var(--spacing-xl); }
|
||||
@ -751,6 +789,12 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<span class="tab-label">Widoczność</span>
|
||||
</button>
|
||||
{% if current_user.can_manage_company(company.id) %}
|
||||
<button type="button" class="ce-tab" data-tab="team">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span class="tab-label">Zespół</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('public.company_edit_save', company_id=company.id) }}" id="companyEditForm" enctype="multipart/form-data">
|
||||
@ -1135,6 +1179,51 @@
|
||||
<a href="{{ url_for('public.company_detail', company_id=company.id) }}" class="btn btn-outline">Anuluj</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if current_user.can_manage_company(company.id) %}
|
||||
<!-- TAB 6: Zespół (outside form — uses AJAX) -->
|
||||
<div class="ce-tab-content" id="tab-team">
|
||||
<div class="team-header">
|
||||
<h3>Zespół firmy</h3>
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="toggleAddForm()">+ Dodaj osobę</button>
|
||||
</div>
|
||||
|
||||
<div class="team-add-form" id="teamAddForm" style="display:none;">
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label for="teamEmail">Email</label>
|
||||
<input type="email" id="teamEmail" class="form-input" placeholder="jan@example.com">
|
||||
</div>
|
||||
<div>
|
||||
<label for="teamName">Imię i nazwisko</label>
|
||||
<input type="text" id="teamName" class="form-input" placeholder="Jan Kowalski">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row-bottom">
|
||||
<div>
|
||||
<label for="teamRole">Rola</label>
|
||||
<select id="teamRole" class="form-input">
|
||||
<option value="EMPLOYEE">Pracownik — może edytować dane firmy</option>
|
||||
<option value="VIEWER">Obserwator — może przeglądać dashboard</option>
|
||||
<option value="MANAGER">Kadra zarządzająca — pełna kontrola</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn btn-primary" onclick="addTeamMember()">Dodaj</button>
|
||||
<button type="button" class="btn btn-outline" onclick="toggleAddForm()" style="margin-left:4px;">Anuluj</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size:var(--font-size-sm); color:var(--text-secondary); margin:var(--spacing-sm) 0 0;">
|
||||
Nowy użytkownik otrzyma email z linkiem do ustawienia hasła.
|
||||
Istniejący użytkownik portalu zostanie od razu dodany do zespołu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="teamMembersList">
|
||||
<div class="team-loading">Ładowanie zespołu...</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="ce-preview" id="livePreview">
|
||||
@ -1578,4 +1667,193 @@ if (previewOverlay) {
|
||||
if (e.target === this) closeMobilePreview();
|
||||
});
|
||||
}
|
||||
|
||||
// Toast notifications
|
||||
function showToast(message, type, duration) {
|
||||
type = type || 'info';
|
||||
duration = duration || 4000;
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'flash flash-' + type;
|
||||
toast.innerHTML = '<span>' + message + '</span><button class="flash-close" onclick="this.parentElement.remove()">×</button>';
|
||||
toast.style.cssText = 'position:fixed;top:20px;right:20px;z-index:9999;min-width:280px;max-width:480px;animation:fadeIn 0.3s;';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(function() { if (toast.parentElement) toast.remove(); }, duration);
|
||||
}
|
||||
|
||||
{% if current_user.can_manage_company(company.id) %}
|
||||
// ===== Team Management =====
|
||||
var TEAM_COMPANY_ID = {{ company.id }};
|
||||
var teamLoaded = false;
|
||||
var CSRF_TOKEN = '{{ csrf_token() }}';
|
||||
|
||||
var PERM_LABELS = {
|
||||
'can_edit_description': 'Edycja opisu',
|
||||
'can_edit_services': 'Edycja usług',
|
||||
'can_edit_contacts': 'Edycja kontaktów',
|
||||
'can_edit_social': 'Edycja social media',
|
||||
'can_manage_classifieds': 'Zarządzanie ogłoszeniami',
|
||||
'can_post_forum': 'Posty na forum',
|
||||
'can_view_analytics': 'Podgląd statystyk'
|
||||
};
|
||||
|
||||
// Hook into tab switcher — lazy load team on first click
|
||||
document.addEventListener('click', function(e) {
|
||||
var tab = e.target.closest('.ce-tab[data-tab="team"]');
|
||||
if (tab && !teamLoaded) {
|
||||
teamLoaded = true;
|
||||
loadTeamMembers();
|
||||
}
|
||||
});
|
||||
|
||||
function toggleAddForm() {
|
||||
var form = document.getElementById('teamAddForm');
|
||||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||||
if (form.style.display === 'block') document.getElementById('teamEmail').focus();
|
||||
}
|
||||
|
||||
function loadTeamMembers() {
|
||||
var list = document.getElementById('teamMembersList');
|
||||
list.innerHTML = '<div class="team-loading">Ładowanie...</div>';
|
||||
|
||||
fetch('/firma/' + TEAM_COMPANY_ID + '/zespol')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) { list.innerHTML = '<div class="team-empty">Błąd: ' + (data.error || '') + '</div>'; return; }
|
||||
if (!data.members.length) { list.innerHTML = '<div class="team-empty">Brak członków zespołu. Dodaj pierwszą osobę.</div>'; return; }
|
||||
|
||||
var html = '';
|
||||
data.members.forEach(function(m) {
|
||||
var initials = (m.name || '?').split(' ').map(function(w) { return w[0]; }).join('').substring(0, 2).toUpperCase();
|
||||
var roleClass = m.role === 'MANAGER' ? 'manager' : (m.role === 'EMPLOYEE' ? 'employee' : 'viewer');
|
||||
var roleLabel = m.role === 'MANAGER' ? 'Kadra zarządzająca' : (m.role === 'EMPLOYEE' ? 'Pracownik' : 'Obserwator');
|
||||
|
||||
html += '<div class="team-member-card" data-user-id="' + m.id + '">';
|
||||
html += '<div class="team-member-avatar">' + initials + '</div>';
|
||||
html += '<div class="team-member-info">';
|
||||
html += '<div class="team-member-name">' + escapeHtml(m.name || m.email);
|
||||
if (m.is_current_user) html += '<span class="team-you-badge">Ty</span>';
|
||||
html += '</div>';
|
||||
html += '<div class="team-member-email">' + escapeHtml(m.email) + '</div>';
|
||||
html += '</div>';
|
||||
html += '<div class="team-member-actions">';
|
||||
|
||||
if (!m.is_current_user) {
|
||||
html += '<select class="team-role-select" onchange="changeRole(' + m.id + ', this.value)">';
|
||||
html += '<option value="VIEWER"' + (m.role === 'VIEWER' ? ' selected' : '') + '>Obserwator</option>';
|
||||
html += '<option value="EMPLOYEE"' + (m.role === 'EMPLOYEE' ? ' selected' : '') + '>Pracownik</option>';
|
||||
html += '<option value="MANAGER"' + (m.role === 'MANAGER' ? ' selected' : '') + '>Kadra zarządzająca</option>';
|
||||
html += '</select>';
|
||||
html += '<button type="button" class="team-btn-remove" onclick="removeTeamMember(' + m.id + ', \'' + escapeHtml(m.name || m.email).replace(/'/g, "\\'") + '\')">Usuń</button>';
|
||||
} else {
|
||||
html += '<span class="team-role-badge ' + roleClass + '">' + roleLabel + '</span>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Permissions for EMPLOYEE
|
||||
if (m.role === 'EMPLOYEE' && !m.is_current_user && m.permissions) {
|
||||
html += '<div class="team-perms-toggle" style="width:100%;">';
|
||||
html += '<div style="font-size:var(--font-size-sm);font-weight:500;margin-bottom:6px;color:var(--text-secondary);">Uprawnienia:</div>';
|
||||
html += '<div class="team-perms-grid">';
|
||||
Object.keys(PERM_LABELS).forEach(function(key) {
|
||||
var checked = m.permissions[key] ? ' checked' : '';
|
||||
html += '<label class="team-perm-item">';
|
||||
html += '<input type="checkbox"' + checked + ' onchange="togglePermission(' + m.id + ', \'' + key + '\', this.checked)">';
|
||||
html += PERM_LABELS[key];
|
||||
html += '</label>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
list.innerHTML = html;
|
||||
})
|
||||
.catch(function() { list.innerHTML = '<div class="team-empty">Nie udało się załadować zespołu.</div>'; });
|
||||
}
|
||||
|
||||
function addTeamMember() {
|
||||
var email = document.getElementById('teamEmail').value.trim();
|
||||
var name = document.getElementById('teamName').value.trim();
|
||||
var role = document.getElementById('teamRole').value;
|
||||
|
||||
if (!email) { showToast('Podaj adres email', 'error'); return; }
|
||||
if (!name) { showToast('Podaj imię i nazwisko', 'error'); return; }
|
||||
|
||||
fetch('/firma/' + TEAM_COMPANY_ID + '/zespol/dodaj', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
|
||||
body: JSON.stringify({ email: email, name: name, role: role })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
document.getElementById('teamEmail').value = '';
|
||||
document.getElementById('teamName').value = '';
|
||||
document.getElementById('teamAddForm').style.display = 'none';
|
||||
loadTeamMembers();
|
||||
} else {
|
||||
showToast(data.error || 'Błąd', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() { showToast('Błąd połączenia', 'error'); });
|
||||
}
|
||||
|
||||
function changeRole(userId, newRole) {
|
||||
fetch('/firma/' + TEAM_COMPANY_ID + '/zespol/' + userId + '/rola', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
|
||||
body: JSON.stringify({ role: newRole })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
loadTeamMembers(); // Re-render to update permissions section
|
||||
} else {
|
||||
showToast(data.error || 'Błąd', 'error');
|
||||
loadTeamMembers(); // Revert select
|
||||
}
|
||||
})
|
||||
.catch(function() { showToast('Błąd połączenia', 'error'); });
|
||||
}
|
||||
|
||||
function togglePermission(userId, permKey, value) {
|
||||
fetch('/firma/' + TEAM_COMPANY_ID + '/zespol/' + userId + '/uprawnienia', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
|
||||
body: JSON.stringify({ permission: permKey, value: value })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) { showToast(data.error || 'Błąd', 'error'); loadTeamMembers(); }
|
||||
})
|
||||
.catch(function() { showToast('Błąd połączenia', 'error'); });
|
||||
}
|
||||
|
||||
function removeTeamMember(userId, userName) {
|
||||
nordaConfirm(
|
||||
'Usunąć ' + userName + ' z zespołu?',
|
||||
'Konto użytkownika nie zostanie usunięte — tylko powiązanie z firmą.',
|
||||
function() {
|
||||
fetch('/firma/' + TEAM_COMPANY_ID + '/zespol/' + userId + '/usun', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN }
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) { showToast(data.message, 'success'); loadTeamMembers(); }
|
||||
else showToast(data.error || 'Błąd', 'error');
|
||||
})
|
||||
.catch(function() { showToast('Błąd połączenia', 'error'); });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user