feat(admin): Add user-company assignment UI from companies panel
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

- Add "Users" button and modal in companies table to view/assign/unassign users
- New endpoint POST /admin/companies/<id>/unassign-user to detach user from company
- New endpoint GET /admin/users/list-all for user dropdown in assignment modal
- Modal shows assigned users with "Unpin" button and dropdown for adding new ones

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-07 17:17:54 +01:00
parent 95c46444c4
commit aa49c18f7a
3 changed files with 206 additions and 1 deletions

View File

@ -407,6 +407,27 @@ def admin_user_assign_company(user_id):
db.close()
@bp.route('/users/list-all')
@login_required
@role_required(SystemRole.OFFICE_MANAGER)
def admin_users_list_all():
"""Get all users as JSON (for dropdowns)"""
db = SessionLocal()
try:
users = db.query(User).order_by(User.name, User.email).all()
return jsonify({
'success': True,
'users': [{
'id': u.id,
'name': u.name,
'email': u.email,
'company_id': u.company_id
} for u in users]
})
finally:
db.close()
@bp.route('/users/<int:user_id>/delete', methods=['POST'])
@login_required
@role_required(SystemRole.ADMIN)

View File

@ -487,6 +487,39 @@ def admin_company_people(company_id):
db.close()
@bp.route('/companies/<int:company_id>/unassign-user', methods=['POST'])
@login_required
@role_required(SystemRole.OFFICE_MANAGER)
def admin_company_unassign_user(company_id):
"""Unassign a user from a company"""
db = SessionLocal()
try:
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
return jsonify({'success': False, 'error': 'Firma nie istnieje'}), 404
data = request.get_json() or {}
user_id = data.get('user_id')
if not user_id:
return jsonify({'success': False, 'error': 'Nie podano ID użytkownika'}), 400
user = db.query(User).filter(User.id == user_id, User.company_id == company_id).first()
if not user:
return jsonify({'success': False, 'error': 'Użytkownik nie jest przypisany do tej firmy'}), 404
user.company_id = None
db.commit()
logger.info(f"Admin {current_user.email} unassigned user {user.email} from company {company.name}")
return jsonify({
'success': True,
'message': f'Użytkownik {user.email} odpięty od {company.name}'
})
finally:
db.close()
@bp.route('/companies/<int:company_id>/users')
@login_required
@role_required(SystemRole.OFFICE_MANAGER)

View File

@ -550,7 +550,12 @@
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
<button class="btn-icon" onclick="openPeopleModal({{ company.id }}, '{{ company.name|e }}')" title="Osoby powiązane">
<button class="btn-icon" onclick="openUsersModal({{ company.id }}, '{{ company.name|e }}')" title="Użytkownicy">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</button>
<button class="btn-icon" onclick="openPeopleModal({{ company.id }}, '{{ company.name|e }}')" title="Osoby KRS">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
@ -705,6 +710,28 @@
</div>
</div>
<!-- Users Modal -->
<div id="usersModal" class="modal">
<div class="modal-content">
<div class="modal-header" id="usersModalTitle">Użytkownicy firmy</div>
<div class="modal-body">
<div id="usersList" class="people-list">
<div class="empty-state">Ładowanie...</div>
</div>
<div style="margin-top: var(--spacing-md); padding-top: var(--spacing-md); border-top: 1px solid var(--border);">
<label class="form-label">Przypisz użytkownika</label>
<div style="display: flex; gap: var(--spacing-sm);">
<select id="assignUserSelect" class="form-control" style="flex: 1;"></select>
<button class="btn btn-primary" onclick="assignUserToCompany()">Przypisz</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeUsersModal()">Zamknij</button>
</div>
</div>
</div>
<!-- Confirm Modal -->
<div id="confirmModal" class="modal">
<div class="modal-content">
@ -951,6 +978,130 @@
document.getElementById('peopleModal').classList.remove('active');
}
// Users Modal
let usersModalCompanyId = null;
async function openUsersModal(companyId, companyName) {
usersModalCompanyId = companyId;
document.getElementById('usersModalTitle').textContent = `Użytkownicy - ${companyName}`;
document.getElementById('usersList').innerHTML = '<div class="empty-state">Ładowanie...</div>';
document.getElementById('usersModal').classList.add('active');
await loadCompanyUsers(companyId);
await loadAvailableUsers();
}
function closeUsersModal() {
usersModalCompanyId = null;
document.getElementById('usersModal').classList.remove('active');
}
async function loadCompanyUsers(companyId) {
try {
const response = await fetch(`/admin/companies/${companyId}/users`);
const data = await response.json();
if (data.success) {
if (data.users.length === 0) {
document.getElementById('usersList').innerHTML = '<div class="empty-state">Brak przypisanych użytkowników</div>';
} else {
let html = '';
data.users.forEach(u => {
html += `
<div class="people-item">
<div class="people-info">
<div class="people-name">${u.name || u.email}</div>
<div class="people-role">${u.email} &middot; ${u.role || 'user'}</div>
</div>
<button class="btn btn-secondary" style="padding: 4px 8px; font-size: var(--font-size-xs);" onclick="unassignUser(${companyId}, ${u.id}, '${u.email}')">Odepnij</button>
</div>
`;
});
document.getElementById('usersList').innerHTML = html;
}
} else {
document.getElementById('usersList').innerHTML = '<div class="empty-state">Błąd pobierania danych</div>';
}
} catch (error) {
document.getElementById('usersList').innerHTML = '<div class="empty-state">Błąd połączenia</div>';
}
}
async function loadAvailableUsers() {
try {
const response = await fetch('/admin/users/list-all');
const data = await response.json();
const select = document.getElementById('assignUserSelect');
select.innerHTML = '<option value="">-- Wybierz użytkownika --</option>';
if (data.success) {
data.users.filter(u => !u.company_id).forEach(u => {
const option = document.createElement('option');
option.value = u.id;
option.textContent = `${u.name || u.email} (${u.email})`;
select.appendChild(option);
});
}
} catch (error) {
console.error('Error loading users:', error);
}
}
async function assignUserToCompany() {
if (!usersModalCompanyId) return;
const userId = document.getElementById('assignUserSelect').value;
if (!userId) {
showToast('Wybierz użytkownika', 'error');
return;
}
try {
const response = await fetch(`/admin/companies/${usersModalCompanyId}/assign-user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ user_id: parseInt(userId) })
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
await loadCompanyUsers(usersModalCompanyId);
await loadAvailableUsers();
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
async function unassignUser(companyId, userId, userEmail) {
try {
const response = await fetch(`/admin/companies/${companyId}/unassign-user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ user_id: userId })
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
await loadCompanyUsers(companyId);
await loadAvailableUsers();
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
// Delete (Archive) Company
function deleteCompany(companyId, companyName) {
document.getElementById('confirmIcon').className = 'modal-icon warning';