nordabiz/templates/admin/users.html
Maciej Pienczyn f959323121
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
feat: add manual role notification email button in /admin/users
Adds envelope icon in AKCJE column that sends an email to the user
with their current company role and permissions summary.
Uses approved v3 email template with Norda Business branding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:52:04 +01:00

2820 lines
99 KiB
HTML

{% extends "base.html" %}
{% block title %}Zarządzanie Użytkownikami - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.admin-header {
margin-bottom: var(--spacing-xl);
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.admin-header-content h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
margin: 0;
}
.admin-header-content p {
margin: var(--spacing-xs) 0 0 0;
}
.btn-add-user {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
background: var(--primary);
color: white;
border: none;
border-radius: var(--radius);
font-size: var(--font-size-base);
font-weight: 500;
cursor: pointer;
transition: var(--transition);
}
.btn-add-user:hover {
opacity: 0.9;
}
.btn-add-user svg {
width: 20px;
height: 20px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-2xl);
}
.stat-card {
background: var(--surface);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
text-align: center;
}
.stat-value {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--primary);
}
.stat-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin-top: var(--spacing-xs);
}
.filter-tabs {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
border-bottom: 2px solid var(--border);
padding-bottom: var(--spacing-sm);
}
.filter-tab {
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: var(--font-size-base);
font-weight: 500;
transition: var(--transition);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.filter-tab:hover {
color: var(--text-primary);
}
.filter-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.section {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.section h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-lg);
color: var(--text-primary);
border-bottom: 2px solid var(--border);
padding-bottom: var(--spacing-sm);
}
.users-table {
width: 100%;
border-collapse: collapse;
}
.users-table th,
.users-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.users-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.users-table tr:hover {
background: var(--background);
}
.user-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.user-name {
font-weight: 500;
color: var(--text-primary);
max-width: none;
overflow: visible;
text-overflow: unset;
white-space: normal;
}
.user-email {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.user-company {
font-size: var(--font-size-sm);
color: var(--primary);
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
}
.badge-admin {
background: #DBEAFE;
color: #1D4ED8;
}
.badge-rada {
background: #FEF3C7;
color: #D97706;
}
.role-select {
padding: 4px 8px;
font-size: var(--font-size-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
cursor: pointer;
min-width: 140px;
}
.role-select:hover:not(:disabled) {
border-color: var(--primary);
}
.role-select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.role-select option {
padding: 4px;
}
.badge-verified {
background: #D1FAE5;
color: #065F46;
}
.badge-unverified {
background: #FEF3C7;
color: #92400E;
}
.action-buttons {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
}
.btn-icon {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
cursor: pointer;
transition: var(--transition);
position: relative;
}
.btn-icon:hover {
background: var(--background);
}
.btn-icon[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: #1F2937;
color: white;
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
z-index: 100;
}
.btn-icon[data-tooltip]:hover::after {
opacity: 1;
}
.btn-icon.admin-toggle {
background: #DBEAFE;
border-color: #3B82F6;
color: #1D4ED8;
}
.btn-icon.admin-toggle.active {
background: #3B82F6;
color: white;
}
.btn-icon.verify-toggle {
background: #D1FAE5;
border-color: #10B981;
color: #065F46;
}
.btn-icon.verify-toggle.active {
background: #10B981;
color: white;
}
.btn-icon.danger:hover {
background: var(--error);
border-color: var(--error);
color: white;
}
.btn-icon svg {
width: 16px;
height: 16px;
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
/* Modal styles */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
max-width: 500px;
width: 90%;
box-shadow: var(--shadow-lg);
}
.modal-header {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-md);
color: var(--text-primary);
}
.modal-body {
margin-bottom: var(--spacing-lg);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-label {
display: block;
margin-bottom: var(--spacing-xs);
color: var(--text-secondary);
font-size: var(--font-size-sm);
font-weight: 500;
}
.form-control {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
font-family: inherit;
}
.form-control:focus {
outline: none;
border-color: var(--primary);
}
.btn {
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--radius);
font-size: var(--font-size-base);
font-weight: 500;
cursor: pointer;
transition: var(--transition);
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
background: var(--background);
color: var(--text-secondary);
}
.btn-secondary:hover {
background: var(--border);
}
.reset-url {
background: var(--background);
padding: var(--spacing-sm);
border-radius: var(--radius);
word-break: break-all;
font-family: monospace;
font-size: var(--font-size-sm);
margin-top: var(--spacing-sm);
}
.copy-btn {
margin-top: var(--spacing-sm);
font-size: var(--font-size-sm);
}
/* Toast Notification System */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
background: var(--surface);
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
gap: var(--spacing-sm);
min-width: 280px;
max-width: 400px;
animation: slideIn 0.3s ease;
border-left: 4px solid var(--primary);
}
.toast.success {
border-left-color: #10B981;
}
.toast.error {
border-left-color: #EF4444;
}
.toast.warning {
border-left-color: #F59E0B;
}
.toast-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.toast-icon.success { color: #10B981; }
.toast-icon.error { color: #EF4444; }
.toast-icon.warning { color: #F59E0B; }
.toast-message {
flex: 1;
color: var(--text-primary);
}
.toast-close {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 4px;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
/* Confirmation Modal */
.modal-icon {
width: 48px;
height: 48px;
margin: 0 auto var(--spacing-md);
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.modal-icon.warning {
background: #FEF3C7;
color: #F59E0B;
}
.modal-icon.danger {
background: #FEE2E2;
color: #EF4444;
}
.modal-icon.success {
background: #D1FAE5;
color: #10B981;
}
.modal-icon svg {
width: 24px;
height: 24px;
}
.modal-title {
font-size: var(--font-size-lg);
font-weight: 600;
text-align: center;
margin-bottom: var(--spacing-sm);
}
.modal-description {
text-align: center;
color: var(--text-secondary);
margin-bottom: var(--spacing-lg);
}
.btn-danger {
background: #EF4444;
color: white;
}
.btn-danger:hover {
background: #DC2626;
}
.btn-success {
background: #10B981;
color: white;
}
.btn-success:hover {
background: #059669;
}
/* Reset URL improved styling */
.reset-url-container {
background: linear-gradient(135deg, #F0F9FF 0%, #E0F2FE 100%);
border: 1px solid #BAE6FD;
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
margin: var(--spacing-md) 0;
}
.reset-url-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
}
.reset-url {
background: var(--surface);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
word-break: break-all;
font-family: monospace;
font-size: var(--font-size-sm);
border: 1px solid var(--border);
max-height: 100px;
overflow-y: auto;
}
.reset-url-info {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: var(--spacing-sm);
}
.reset-url-info svg {
width: 16px;
height: 16px;
color: #F59E0B;
}
.copy-btn-row {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-md);
}
.copy-btn-row .btn {
flex: 1;
}
.copied-indicator {
color: #10B981;
font-weight: 500;
}
@media (max-width: 768px) {
.users-table {
font-size: var(--font-size-sm);
}
.users-table th:nth-child(4),
.users-table td:nth-child(4),
.users-table th:nth-child(5),
.users-table td:nth-child(5),
.users-table th:nth-child(6),
.users-table td:nth-child(6) {
display: none;
}
}
/* AI User Creation Styles */
.btn-add-user-ai {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
background: linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%);
color: white;
border: none;
border-radius: var(--radius);
font-size: var(--font-size-base);
font-weight: 500;
cursor: pointer;
transition: var(--transition);
margin-left: var(--spacing-sm);
}
.btn-add-user-ai:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.btn-add-user-ai svg {
width: 20px;
height: 20px;
}
.header-buttons {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
/* AI Modal - wider */
#aiUserModal .modal-content {
max-width: 700px;
width: 95%;
max-height: 90vh;
overflow-y: auto;
}
.ai-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border);
}
.ai-modal-title {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.ai-badge {
background: linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%);
color: white;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 600;
}
.ai-steps {
display: flex;
align-items: center;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-xl);
}
.ai-step {
display: flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.ai-step.active {
color: var(--primary);
font-weight: 500;
}
.ai-step.completed {
color: #10B981;
}
.ai-step-number {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-weight: 600;
}
.ai-step.active .ai-step-number {
background: var(--primary);
color: white;
}
.ai-step.completed .ai-step-number {
background: #10B981;
color: white;
}
.ai-step-line {
width: 40px;
height: 2px;
background: var(--border);
}
.ai-step.completed + .ai-step-line {
background: #10B981;
}
/* Step 1 - Input */
.ai-input-section {
margin-bottom: var(--spacing-lg);
}
.ai-input-tabs {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.ai-input-tab {
flex: 1;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--background);
border: 2px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
text-align: center;
transition: var(--transition);
font-weight: 500;
}
.ai-input-tab:hover {
border-color: var(--primary);
}
.ai-input-tab.active {
background: #EEF2FF;
border-color: var(--primary);
color: var(--primary);
}
.ai-input-tab svg {
width: 20px;
height: 20px;
margin-bottom: var(--spacing-xs);
display: block;
margin-left: auto;
margin-right: auto;
}
.ai-textarea {
width: 100%;
min-height: 150px;
padding: var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: inherit;
font-size: var(--font-size-base);
resize: vertical;
}
.ai-textarea:focus {
outline: none;
border-color: var(--primary);
}
.ai-file-upload {
border: 2px dashed var(--border);
border-radius: var(--radius-lg);
padding: var(--spacing-2xl);
text-align: center;
cursor: pointer;
transition: var(--transition);
}
.ai-file-upload:hover {
border-color: var(--primary);
background: #F8FAFC;
}
.ai-file-upload.drag-over {
border-color: var(--primary);
background: #EEF2FF;
}
.ai-file-upload svg {
width: 48px;
height: 48px;
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.ai-file-upload p {
color: var(--text-secondary);
margin: 0;
}
.ai-file-preview {
margin-top: var(--spacing-md);
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.ai-file-preview img {
max-width: 100px;
max-height: 100px;
border-radius: var(--radius);
object-fit: cover;
}
.ai-file-preview .file-info {
flex: 1;
}
.ai-file-preview .file-name {
font-weight: 500;
margin-bottom: var(--spacing-xs);
}
.ai-file-preview .file-size {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.ai-file-preview .remove-file {
color: var(--error);
cursor: pointer;
padding: var(--spacing-sm);
}
.ai-hint {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: var(--spacing-sm);
}
/* Step 2 - Review */
.ai-message {
padding: var(--spacing-md);
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-md);
}
.ai-message.assistant {
background: linear-gradient(135deg, #EEF2FF 0%, #E0E7FF 100%);
border-left: 4px solid #6366F1;
}
.ai-message.user {
background: var(--background);
border-left: 4px solid var(--border);
}
.ai-message-header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
font-weight: 500;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.ai-message-header svg {
width: 16px;
height: 16px;
}
.ai-users-table {
width: 100%;
border-collapse: collapse;
margin-top: var(--spacing-md);
font-size: var(--font-size-sm);
}
.ai-users-table th,
.ai-users-table td {
padding: var(--spacing-sm);
text-align: left;
border-bottom: 1px solid var(--border);
}
.ai-users-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-xs);
text-transform: uppercase;
}
.ai-users-table tr:hover {
background: rgba(99, 102, 241, 0.05);
}
.ai-user-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
}
.ai-user-warning {
color: #F59E0B;
font-size: var(--font-size-xs);
display: flex;
align-items: center;
gap: 4px;
}
.ai-user-warning svg {
width: 14px;
height: 14px;
}
.ai-duplicate-badge {
background: #FEF3C7;
color: #92400E;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
}
/* Step 3 - Results */
.ai-results-list {
margin-top: var(--spacing-md);
}
.ai-result-item {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
border-radius: var(--radius);
margin-bottom: var(--spacing-sm);
background: var(--background);
}
.ai-result-item.success {
background: #D1FAE5;
border-left: 4px solid #10B981;
}
.ai-result-item.error {
background: #FEE2E2;
border-left: 4px solid #EF4444;
}
.ai-result-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.ai-result-icon.success { color: #10B981; }
.ai-result-icon.error { color: #EF4444; }
.ai-result-info {
flex: 1;
}
.ai-result-email {
font-weight: 500;
}
.ai-result-password {
font-family: monospace;
background: rgba(0,0,0,0.05);
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
margin-left: var(--spacing-sm);
}
.ai-result-copy {
background: none;
border: 1px solid var(--border);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius);
cursor: pointer;
font-size: var(--font-size-sm);
transition: var(--transition);
}
.ai-result-copy:hover {
background: var(--surface);
}
/* Loading state */
.ai-loading {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--spacing-2xl);
}
.ai-spinner {
width: 48px;
height: 48px;
border: 4px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.ai-loading-text {
margin-top: var(--spacing-md);
color: var(--text-secondary);
}
/* Modal footer */
.ai-modal-footer {
display: flex;
justify-content: space-between;
margin-top: var(--spacing-xl);
padding-top: var(--spacing-md);
border-top: 1px solid var(--border);
}
.ai-modal-footer .btn {
min-width: 120px;
}
</style>
{% endblock %}
{% block content %}
<div class="admin-header">
<div class="admin-header-content">
<h1>Zarządzanie Użytkownikami</h1>
<p class="text-muted">Zarządzaj kontami użytkowników platformy</p>
</div>
<div class="header-buttons">
<button class="btn-add-user" onclick="openAddUserModal()">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/>
</svg>
Dodaj użytkownika
</button>
<button class="btn-add-user-ai" onclick="openAIUserModal()">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
Dodaj z AI
</button>
</div>
</div>
{% if pending_company_count > 0 %}
<div style="background: #FEF3C7; border: 1px solid #F59E0B; border-radius: var(--radius); padding: var(--spacing-md) var(--spacing-lg); margin-bottom: var(--spacing-lg); display: flex; align-items: center; gap: var(--spacing-md);">
<span style="font-size: 1.3em;">⚠️</span>
<span><strong>{{ pending_company_count }}</strong> {{ 'użytkownik czeka' if pending_company_count == 1 else 'użytkowników czeka' }} na zatwierdzenie uprawnień firmowych (firma przypisana, rola: Brak)</span>
</div>
{% endif %}
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ total_users }}</div>
<div class="stat-label">Wszystkich</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #3B82F6;">{{ admin_count }}</div>
<div class="stat-label">Adminów</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #10B981;">{{ verified_count }}</div>
<div class="stat-label">Zweryfikowanych</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #F59E0B;">{{ unverified_count }}</div>
<div class="stat-label">Niezweryfikowanych</div>
</div>
</div>
<!-- Users Section -->
<div class="section">
<h2>Użytkownicy</h2>
<!-- Filter Tabs -->
<div class="filter-tabs">
<button class="filter-tab active" data-filter="all">Wszyscy</button>
<button class="filter-tab" data-filter="admin">Admini ({{ admin_count }})</button>
<button class="filter-tab" data-filter="verified">Zweryfikowani ({{ verified_count }})</button>
<button class="filter-tab" data-filter="unverified">Niezweryfikowani ({{ unverified_count }})</button>
{% if pending_company_count > 0 %}
<button class="filter-tab" data-filter="pending-company" style="color: #D97706;">Oczekujący ({{ pending_company_count }})</button>
{% endif %}
</div>
{% if users %}
<table class="users-table">
<thead>
<tr>
<th>ID</th>
<th>Użytkownik</th>
<th>Firma</th>
<th>Upr. firmowe</th>
<th>Rola</th>
<th>Utworzono</th>
<th>Ostatnie logowanie</th>
<th>Status</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr data-user-id="{{ user.id }}"
data-role="{{ user.role }}"
data-is-verified="{{ 'true' if user.is_verified else 'false' }}"
data-pending-company="{{ 'true' if user.company_id and user.company_role == 'NONE' else 'false' }}">
<td>{{ user.id }}</td>
<td>
<div class="user-info">
<span class="user-name">{{ user.name or '(brak nazwy)' }}</span>
<span class="user-email">{{ user.email }}</span>
</div>
</td>
<td>
{% if user.company %}
<a href="{{ url_for('company_detail_by_slug', slug=user.company.slug) }}"
class="user-company" target="_blank">
{{ user.company.name }}
</a>
{% else %}
<span style="color: var(--text-secondary);">-</span>
{% endif %}
</td>
<td>
{% if user.company %}
<select class="role-select" style="font-size: var(--font-size-sm);"
data-user-id="{{ user.id }}"
onchange="changeCompanyRole({{ user.id }}, this.value)">
<option value="NONE" {% if user.company_role == 'NONE' %}selected{% endif %}>Brak</option>
<option value="VIEWER" {% if user.company_role == 'VIEWER' %}selected{% endif %}>Podgląd</option>
<option value="EMPLOYEE" {% if user.company_role == 'EMPLOYEE' %}selected{% endif %}>Pracownik</option>
<option value="MANAGER" {% if user.company_role == 'MANAGER' %}selected{% endif %}>Zarządzający</option>
</select>
{% else %}
<span style="color: var(--text-secondary);">-</span>
{% endif %}
</td>
<td>
<select class="role-select"
data-user-id="{{ user.id }}"
onchange="changeUserRole({{ user.id }}, this.value)"
{% if user.id == current_user.id %}disabled title="Nie możesz zmienić swojej roli"{% endif %}>
<option value="UNAFFILIATED" {% if user.role == 'UNAFFILIATED' %}selected{% endif %}>Niezrzeszony</option>
<option value="MEMBER" {% if user.role == 'MEMBER' %}selected{% endif %}>Członek</option>
<option value="EMPLOYEE" {% if user.role == 'EMPLOYEE' %}selected{% endif %}>Pracownik</option>
<option value="MANAGER" {% if user.role == 'MANAGER' %}selected{% endif %}>Kadra Zarządzająca</option>
<option value="OFFICE_MANAGER" {% if user.role == 'OFFICE_MANAGER' %}selected{% endif %}>Kierownik Biura</option>
<option value="ADMIN" {% if user.role == 'ADMIN' %}selected{% endif %}>Administrator</option>
</select>
</td>
<td style="font-size: var(--font-size-sm); color: var(--text-secondary);">
{{ user.created_at.strftime('%d.%m.%Y %H:%M') }}
</td>
<td style="font-size: var(--font-size-sm); color: var(--text-secondary);">
{% if user.last_login %}
{{ user.last_login.strftime('%d.%m.%Y %H:%M') }}
{% else %}
<span style="color: var(--text-muted, #9CA3AF);">Nigdy</span>
{% endif %}
</td>
<td>
{% if user.can_manage_users() %}
<span class="badge badge-admin">Admin</span>
{% endif %}
{% if user.is_rada_member %}
<span class="badge badge-rada">Rada</span>
{% endif %}
{% if user.is_verified %}
<span class="badge badge-verified">Zweryfikowany</span>
{% else %}
<span class="badge badge-unverified">Niezweryfikowany</span>
{% endif %}
</td>
<td>
<div class="action-buttons">
<!-- Edit User -->
<button class="btn-icon"
onclick="openEditUserModal({{ user.id }}, '{{ user.name|e if user.name else '' }}', '{{ user.email|e }}', '{{ user.phone|e if user.phone else '' }}')"
data-tooltip="Edytuj dane">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<!-- Toggle Admin -->
<button class="btn-icon admin-toggle {{ 'active' if user.can_manage_users() else '' }}"
onclick="toggleAdmin({{ user.id }})"
data-tooltip="{{ 'Odbierz uprawnienia admina' if user.can_manage_users() else 'Nadaj uprawnienia admina' }}"
{% if user.id == current_user.id %}disabled{% endif %}>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
</button>
<!-- Toggle Verified -->
<button class="btn-icon verify-toggle {{ 'active' if user.is_verified else '' }}"
onclick="toggleVerified({{ user.id }})"
data-tooltip="{{ 'Cofnij weryfikację' if user.is_verified else 'Zweryfikuj użytkownika' }}">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
<!-- Toggle Rada Member -->
<button class="btn-icon rada-toggle {{ 'active' if user.is_rada_member else '' }}"
onclick="toggleRadaMember({{ user.id }})"
data-tooltip="{{ 'Usuń z Rady Izby' if user.is_rada_member else 'Dodaj do Rady Izby' }}">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<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>
</button>
<!-- Assign Company -->
<button class="btn-icon"
onclick="openCompanyModal({{ user.id }}, '{{ user.name|e }}', {{ user.company_id or 'null' }})"
data-tooltip="Przypisz firmę">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</button>
<!-- Reset Password -->
<button class="btn-icon"
onclick="openPasswordModal({{ user.id }}, '{{ user.email|e }}')"
data-tooltip="Resetuj hasło">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</button>
<!-- Send role notification -->
{% if user.company and user.email %}
<button class="btn-icon"
onclick="sendRoleNotification({{ user.id }}, '{{ user.name|e }}', '{{ user.email|e }}')"
data-tooltip="Wyślij powiadomienie o uprawnieniach">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</button>
{% endif %}
<!-- Delete -->
<button class="btn-icon danger"
onclick="deleteUser({{ user.id }}, '{{ user.email|e }}')"
data-tooltip="Usuń użytkownika"
{% if user.id == current_user.id %}disabled{% endif %}>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>Brak użytkowników</p>
</div>
{% endif %}
</div>
<!-- Add User Modal -->
<div id="addUserModal" class="modal">
<div class="modal-content">
<div class="modal-header">Dodaj nowego użytkownika</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Email *</label>
<input type="email" id="addUserEmail" class="form-control" placeholder="jan.kowalski@firma.pl" required>
</div>
<div class="form-group">
<label class="form-label">Imię i nazwisko</label>
<input type="text" id="addUserName" class="form-control" placeholder="Jan Kowalski">
</div>
<div class="form-group">
<label class="form-label">Firma</label>
<select id="addUserCompany" class="form-control">
<option value="">-- Brak firmy --</option>
{% for company in companies %}
<option value="{{ company.id }}">{{ company.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="display: flex; gap: var(--spacing-lg); align-items: flex-end;">
<div>
<label>Rola</label>
<select id="addUserRole" class="form-control" style="min-width: 160px;">
<option value="MEMBER">Członek</option>
<option value="EMPLOYEE">Pracownik</option>
<option value="MANAGER">Kadra Zarządzająca</option>
<option value="OFFICE_MANAGER">Kierownik Biura</option>
<option value="ADMIN">Administrator</option>
</select>
</div>
<label style="display: flex; align-items: center; gap: var(--spacing-xs); cursor: pointer;">
<input type="checkbox" id="addUserVerified" checked>
Zweryfikowany
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeAddUserModal()">Anuluj</button>
<button class="btn btn-primary" onclick="confirmAddUser()">Utwórz użytkownika</button>
</div>
</div>
</div>
<!-- New User Created Modal (show generated password) -->
<div id="newUserCreatedModal" class="modal">
<div class="modal-content">
<div class="modal-icon success">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div class="modal-title">Użytkownik utworzony</div>
<div class="modal-description" id="newUserCreatedEmail"></div>
<div class="reset-url-container">
<div class="reset-url-label">Wygenerowane hasło (przekaż użytkownikowi):</div>
<div id="newUserPassword" class="reset-url" style="font-weight: bold; font-size: var(--font-size-lg);"></div>
<div class="reset-url-info">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span>Użytkownik powinien zmienić hasło po pierwszym logowaniu</span>
</div>
</div>
<div class="copy-btn-row">
<button class="btn btn-primary" onclick="copyNewUserPassword()">
<span id="copyPasswordBtnText">Skopiuj hasło</span>
</button>
<button class="btn btn-secondary" onclick="closeNewUserCreatedModal()">Zamknij</button>
</div>
</div>
</div>
<!-- Company Assignment Modal -->
<div id="companyModal" class="modal">
<div class="modal-content">
<div class="modal-header">Przypisz firmę</div>
<div class="modal-body">
<p id="companyModalUser" style="margin-bottom: var(--spacing-md);"></p>
<div class="form-group">
<label class="form-label">Wybierz firmę</label>
<select id="companySelect" class="form-control">
<option value="">-- Brak firmy --</option>
{% for company in companies %}
<option value="{{ company.id }}">{{ company.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeCompanyModal()">Anuluj</button>
<button class="btn btn-primary" onclick="confirmAssignCompany()">Zapisz</button>
</div>
</div>
</div>
<!-- Password Management Modal -->
<div id="passwordModal" class="modal">
<div class="modal-content" style="max-width: 520px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-lg);">
<div class="modal-header" style="margin-bottom: 0;">Resetuj hasło</div>
<button class="btn-icon" onclick="closePasswordModal()" style="border: none;">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); margin-bottom: var(--spacing-lg);">
Użytkownik: <strong id="pwModalEmail"></strong>
</div>
<!-- Tabs -->
<div style="display: flex; gap: var(--spacing-sm); margin-bottom: var(--spacing-lg);">
<button class="btn btn-primary" id="pwTabSet" onclick="switchPwTab('set')" style="flex: 1;">Ustaw hasło</button>
<button class="btn btn-secondary" id="pwTabLink" onclick="switchPwTab('link')" style="flex: 1;">Wyślij link</button>
</div>
<!-- Tab: Set Password -->
<div id="pwSetPanel">
<div class="form-group">
<label class="form-label">Nowe hasło</label>
<div style="display: flex; gap: var(--spacing-xs);">
<div style="flex: 1; position: relative;">
<input type="password" id="pwNewPassword" class="form-control" placeholder="Min. 8 znaków" autocomplete="new-password">
<button type="button" onclick="togglePwVisibility()" style="position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; color: var(--text-secondary); padding: 4px;">
<svg id="pwEyeIcon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="18" height="18">
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</button>
</div>
<button class="btn btn-secondary" onclick="generatePassword()" style="white-space: nowrap;">Generuj</button>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closePasswordModal()">Anuluj</button>
<button class="btn btn-primary" onclick="saveDirectPassword()">Zapisz hasło</button>
</div>
</div>
<!-- Tab: Reset Link -->
<div id="pwLinkPanel" style="display: none;">
<div class="modal-description" style="text-align: left;">
Wygeneruj jednorazowy link, który pozwoli użytkownikowi samodzielnie ustawić nowe hasło. Link jest ważny 1 godzinę.
</div>
<div id="pwLinkResult" style="display: none;">
<div class="reset-url-container">
<div class="reset-url-label">Link do resetu hasła:</div>
<div id="resetUrl" class="reset-url"></div>
<div class="reset-url-info">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>Link ważny przez 1 godzinę</span>
</div>
</div>
<div style="margin-top: var(--spacing-md);">
<button class="btn btn-primary" onclick="copyResetUrl()" style="width: 100%;">
<span id="copyBtnText">Skopiuj link</span>
</button>
</div>
</div>
<div class="modal-footer" id="pwLinkActions">
<button class="btn btn-secondary" onclick="closePasswordModal()">Anuluj</button>
<button class="btn btn-primary" onclick="generateResetLink()">Generuj link</button>
</div>
</div>
</div>
</div>
<!-- Edit User Modal -->
<div id="editUserModal" class="modal">
<div class="modal-content">
<div class="modal-header">Edytuj użytkownika</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Imię i nazwisko</label>
<input type="text" id="editUserName" class="form-control" placeholder="Jan Kowalski">
</div>
<div class="form-group">
<label class="form-label">Email</label>
<input type="email" id="editUserEmail" class="form-control" placeholder="jan@firma.pl">
</div>
<div class="form-group">
<label class="form-label">Telefon</label>
<input type="text" id="editUserPhone" class="form-control" placeholder="+48 123 456 789">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeEditUserModal()">Anuluj</button>
<button class="btn btn-primary" onclick="saveEditUser()">Zapisz zmiany</button>
</div>
</div>
</div>
<!-- Confirmation Modal -->
<div id="confirmModal" class="modal">
<div class="modal-content">
<div id="confirmIcon" class="modal-icon warning">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div id="confirmTitle" class="modal-title">Potwierdzenie</div>
<div id="confirmDescription" class="modal-description">Czy na pewno chcesz wykonać tę akcję?</div>
<div class="modal-footer" style="justify-content: center;">
<button class="btn btn-secondary" onclick="closeConfirmModal()">Anuluj</button>
<button id="confirmAction" class="btn btn-primary">Potwierdź</button>
</div>
</div>
</div>
<!-- AI User Creation Modal -->
<div id="aiUserModal" class="modal">
<div class="modal-content">
<div class="ai-modal-header">
<div class="ai-modal-title">
Tworzenie użytkowników
<span class="ai-badge">Gemini AI</span>
</div>
<button class="btn-icon" onclick="closeAIUserModal()" data-tooltip="Zamknij">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Steps Indicator -->
<div class="ai-steps">
<div class="ai-step active" id="aiStep1">
<span class="ai-step-number">1</span>
Dane
</div>
<div class="ai-step-line"></div>
<div class="ai-step" id="aiStep2">
<span class="ai-step-number">2</span>
Przegląd
</div>
<div class="ai-step-line"></div>
<div class="ai-step" id="aiStep3">
<span class="ai-step-number">3</span>
Wyniki
</div>
</div>
<!-- Step 1: Input -->
<div id="aiStepInput" class="ai-step-content">
<div class="ai-input-section">
<div class="ai-input-tabs">
<button class="ai-input-tab active" onclick="switchAIInputTab('text', event)">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Tekst
</button>
<button class="ai-input-tab" onclick="switchAIInputTab('image', event)">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
Screenshot
</button>
</div>
<!-- Text Input -->
<div id="aiTextInput">
<textarea id="aiUserText" class="ai-textarea" placeholder="Wklej dane użytkowników w dowolnym formacie, np.:
Jan Kowalski jan@pixlab.pl admin
Anna Nowak anna@test.pl
Piotr Wiśniewski piotr@waterm.pl user
Lub format CSV, Excel, lista emaili..."></textarea>
<p class="ai-hint">
<strong>Dowolne źródło i format</strong> — email, Excel, CRM, notatki, wiadomość ze Slacka...
AI przeanalizuje tekst i wyodrębni dane użytkowników.
</p>
</div>
<!-- Image Input -->
<div id="aiImageInput" style="display: none;">
<div class="ai-file-upload" id="aiFileDropzone" onclick="document.getElementById('aiFileInput').click()">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p><strong>Wklej ze schowka:</strong>
<span style="display: inline-flex; align-items: center; gap: 3px; margin: 0 5px;">
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor" style="opacity: 0.5;"><path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801"/></svg>
<code style="background: rgba(138,99,210,0.25); padding: 3px 8px; border-radius: 4px; font-size: 13px; font-weight: 600;">Ctrl+V</code>
</span>
<span style="display: inline-flex; align-items: center; gap: 3px; margin: 0 5px;">
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor" style="opacity: 0.5;"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/></svg>
<code style="background: rgba(138,99,210,0.25); padding: 3px 8px; border-radius: 4px; font-size: 13px; font-weight: 600;">Cmd+V</code>
</span>
lub przeciągnij</p>
<p style="font-size: var(--font-size-sm);">PNG, JPG do 5MB • Możesz też kliknąć i wybrać plik</p>
</div>
<input type="file" id="aiFileInput" accept="image/*" style="display: none;" onchange="handleAIFileSelect(event)">
<div id="aiFilePreview" class="ai-file-preview" style="display: none;">
<img id="aiPreviewImg" src="" alt="Preview">
<div class="file-info">
<div class="file-name" id="aiFileName"></div>
<div class="file-size" id="aiFileSize"></div>
</div>
<button class="remove-file" onclick="removeAIFile()">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="20" height="20">
<path d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<p class="ai-hint">AI odczyta tekst z obrazu i wyodrębni dane użytkowników.</p>
</div>
</div>
</div>
<!-- Step 2: Processing / Review -->
<div id="aiStepReview" class="ai-step-content" style="display: none;">
<!-- Loading state -->
<div id="aiLoading" class="ai-loading">
<div class="ai-spinner"></div>
<p class="ai-loading-text">AI analizuje dane...</p>
</div>
<!-- AI Response -->
<div id="aiResponse" style="display: none;">
<div class="ai-message assistant">
<div class="ai-message-header">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
Gemini AI
</div>
<p id="aiAnalysisText"></p>
</div>
<!-- Duplicates warning -->
<div id="aiDuplicatesWarning" style="display: none; margin-bottom: var(--spacing-md);">
<div class="ai-message" style="background: #FEF3C7; border-left-color: #F59E0B;">
<div class="ai-message-header" style="color: #92400E;">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
Uwaga - duplikaty
</div>
<p id="aiDuplicatesText"></p>
</div>
</div>
<!-- Users table -->
<div id="aiUsersTableContainer">
<table class="ai-users-table">
<thead>
<tr>
<th style="width: 40px;"></th>
<th>Email</th>
<th>Nazwa</th>
<th>Firma</th>
<th>Admin</th>
<th></th>
</tr>
</thead>
<tbody id="aiUsersTableBody">
</tbody>
</table>
</div>
</div>
</div>
<!-- Step 3: Results -->
<div id="aiStepResults" class="ai-step-content" style="display: none;">
<div class="ai-message assistant">
<div class="ai-message-header">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Zakończono
</div>
<p id="aiResultsSummary"></p>
</div>
<div id="aiResultsList" class="ai-results-list">
</div>
</div>
<!-- Footer -->
<div class="ai-modal-footer">
<button id="aiBackBtn" class="btn btn-secondary" onclick="aiGoBack()" style="display: none;">
Wstecz
</button>
<div></div>
<button id="aiNextBtn" class="btn btn-primary" onclick="aiGoNext()">
Analizuj z AI
</button>
</div>
</div>
</div>
<!-- Toast Container -->
<div id="toastContainer" class="toast-container"></div>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
let currentUserId = null;
let confirmCallback = null;
let editUserId = null;
// Edit User functions
function openEditUserModal(userId, name, email, phone) {
editUserId = userId;
document.getElementById('editUserName').value = name || '';
document.getElementById('editUserEmail').value = email || '';
document.getElementById('editUserPhone').value = phone || '';
document.getElementById('editUserModal').classList.add('active');
}
function closeEditUserModal() {
editUserId = null;
document.getElementById('editUserModal').classList.remove('active');
}
async function saveEditUser() {
if (!editUserId) return;
const name = document.getElementById('editUserName').value.trim();
const email = document.getElementById('editUserEmail').value.trim();
const phone = document.getElementById('editUserPhone').value.trim();
if (!email) {
showToast('Email jest wymagany', 'error');
return;
}
try {
const response = await fetch(`/admin/users/${editUserId}/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
name: name || null,
email: email,
phone: phone || null
})
});
const data = await response.json();
if (data.success) {
closeEditUserModal();
showToast(data.message || 'Zapisano zmiany', 'success');
location.reload();
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
// Toast notification system
function showToast(message, type = 'success') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const iconSvg = {
success: '<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>',
error: '<path d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>',
warning: '<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>'
};
toast.innerHTML = `
<svg class="toast-icon ${type}" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
${iconSvg[type] || iconSvg.success}
</svg>
<span class="toast-message">${message}</span>
<button class="toast-close" onclick="this.parentElement.remove()">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
`;
container.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease forwards';
setTimeout(() => toast.remove(), 300);
}, 5000);
}
function showMessage(message, type) {
showToast(message, type);
}
// Confirmation modal system
function showConfirmModal(title, description, callback, iconType = 'warning', buttonText = 'Potwierdź', buttonClass = 'btn-primary') {
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmDescription').textContent = description;
const icon = document.getElementById('confirmIcon');
icon.className = `modal-icon ${iconType}`;
const iconSvg = {
warning: '<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>',
danger: '<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>',
success: '<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>'
};
icon.innerHTML = `<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">${iconSvg[iconType] || iconSvg.warning}</svg>`;
const actionBtn = document.getElementById('confirmAction');
actionBtn.textContent = buttonText;
actionBtn.className = `btn ${buttonClass}`;
confirmCallback = callback;
document.getElementById('confirmModal').classList.add('active');
}
function closeConfirmModal() {
document.getElementById('confirmModal').classList.remove('active');
confirmCallback = null;
}
document.getElementById('confirmAction').addEventListener('click', function() {
if (confirmCallback) {
confirmCallback();
}
closeConfirmModal();
});
// Filter tabs functionality
document.querySelectorAll('.filter-tab').forEach(tab => {
tab.addEventListener('click', function() {
document.querySelectorAll('.filter-tab').forEach(t => t.classList.remove('active'));
this.classList.add('active');
const filter = this.dataset.filter;
document.querySelectorAll('[data-user-id]').forEach(row => {
const isAdmin = row.dataset.role === 'ADMIN';
const isVerified = row.dataset.isVerified === 'true';
let show = false;
const isPendingCompany = row.dataset.pendingCompany === 'true';
if (filter === 'all') show = true;
else if (filter === 'admin') show = isAdmin;
else if (filter === 'verified') show = isVerified;
else if (filter === 'unverified') show = !isVerified;
else if (filter === 'pending-company') show = isPendingCompany;
row.style.display = show ? '' : 'none';
});
});
});
async function changeUserRole(userId, newRole) {
const select = document.querySelector(`select.role-select[data-user-id="${userId}"]`);
const originalValue = select.dataset.originalValue || select.value;
try {
const response = await fetch('/admin/users-api/change-role', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
user_id: userId,
role: newRole
})
});
const data = await response.json();
if (data.success) {
select.dataset.originalValue = newRole;
showMessage(`Rola zmieniona na: ${getRoleLabel(newRole)}`, 'success');
// Update admin toggle button state if role changed to/from ADMIN
const row = select.closest('tr');
const adminBtn = row.querySelector('.admin-toggle');
if (adminBtn) {
if (newRole === 'ADMIN') {
adminBtn.classList.add('active');
} else {
adminBtn.classList.remove('active');
}
}
} else {
select.value = originalValue;
showMessage(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
select.value = originalValue;
showMessage('Błąd połączenia', 'error');
}
}
async function changeCompanyRole(userId, newRole) {
const selects = document.querySelectorAll(`select[data-user-id="${userId}"]`);
const select = Array.from(selects).find(s => s.onchange?.toString().includes('changeCompanyRole'));
if (!select) return;
const originalValue = select.dataset.originalValue || select.value;
try {
const response = await fetch('/admin/users-api/change-company-role', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
user_id: userId,
company_role: newRole
})
});
const data = await response.json();
if (data.success) {
select.dataset.originalValue = newRole;
const labels = {'NONE': 'Brak', 'VIEWER': 'Podgląd', 'EMPLOYEE': 'Pracownik', 'MANAGER': 'Zarządzający'};
showMessage(`Uprawnienia firmowe zmienione na: ${labels[newRole] || newRole}`, 'success');
} else {
select.value = originalValue;
showMessage(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
select.value = originalValue;
showMessage('Błąd połączenia', 'error');
}
}
async function sendRoleNotification(userId, userName, userEmail) {
if (!confirm(`Wysłać powiadomienie o uprawnieniach do ${userName} (${userEmail})?`)) return;
try {
const response = await fetch(`/admin/users-api/${userId}/send-role-notification`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
showMessage(data.message, 'success');
} else {
showMessage(data.error || 'Błąd wysyłania', 'error');
}
} catch (error) {
showMessage('Błąd połączenia', 'error');
}
}
function getRoleLabel(role) {
const labels = {
'UNAFFILIATED': 'Niezrzeszony',
'MEMBER': 'Członek',
'EMPLOYEE': 'Pracownik',
'MANAGER': 'Kadra Zarządzająca',
'OFFICE_MANAGER': 'Kierownik Biura',
'ADMIN': 'Administrator'
};
return labels[role] || role;
}
async function toggleAdmin(userId) {
try {
const response = await fetch(`/admin/users/${userId}/toggle-admin`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
showMessage(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showMessage('Błąd połączenia', 'error');
}
}
async function toggleVerified(userId) {
try {
const response = await fetch(`/admin/users/${userId}/toggle-verified`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
showMessage(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showMessage('Błąd połączenia', 'error');
}
}
async function toggleRadaMember(userId) {
try {
const response = await fetch(`/admin/users/${userId}/toggle-rada-member`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
showMessage(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showMessage('Błąd połączenia', 'error');
}
}
// Add User functions
function openAddUserModal() {
// Reset form
document.getElementById('addUserEmail').value = '';
document.getElementById('addUserName').value = '';
document.getElementById('addUserCompany').value = '';
document.getElementById('addUserRole').value = 'MEMBER';
document.getElementById('addUserVerified').checked = true;
document.getElementById('addUserModal').classList.add('active');
}
function closeAddUserModal() {
document.getElementById('addUserModal').classList.remove('active');
}
async function confirmAddUser() {
const email = document.getElementById('addUserEmail').value.trim();
const name = document.getElementById('addUserName').value.trim();
const companyId = document.getElementById('addUserCompany').value || null;
const role = document.getElementById('addUserRole').value;
const isVerified = document.getElementById('addUserVerified').checked;
if (!email) {
showToast('Email jest wymagany', 'error');
return;
}
// Basic email validation
if (!email.includes('@') || !email.includes('.')) {
showToast('Podaj poprawny adres email', 'error');
return;
}
try {
const response = await fetch('/admin/users/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
email: email,
name: name || null,
company_id: companyId ? parseInt(companyId) : null,
role: role,
is_verified: isVerified
})
});
const data = await response.json();
if (data.success) {
closeAddUserModal();
// Show the created user modal with password
document.getElementById('newUserCreatedEmail').textContent = `Utworzono konto: ${email}`;
document.getElementById('newUserPassword').textContent = data.generated_password;
document.getElementById('copyPasswordBtnText').textContent = 'Skopiuj hasło';
document.getElementById('newUserCreatedModal').classList.add('active');
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
function closeNewUserCreatedModal() {
document.getElementById('newUserCreatedModal').classList.remove('active');
location.reload(); // Reload to show new user
}
function copyNewUserPassword() {
const password = document.getElementById('newUserPassword').textContent;
navigator.clipboard.writeText(password).then(() => {
document.getElementById('copyPasswordBtnText').textContent = 'Skopiowano!';
showToast('Hasło skopiowane do schowka', 'success');
setTimeout(() => {
document.getElementById('copyPasswordBtnText').textContent = 'Skopiuj hasło';
}, 2000);
}).catch(() => {
showToast('Nie udało się skopiować', 'error');
});
}
function openCompanyModal(userId, userName, currentCompanyId) {
currentUserId = userId;
document.getElementById('companyModalUser').textContent = `Użytkownik: ${userName}`;
document.getElementById('companySelect').value = currentCompanyId || '';
document.getElementById('companyModal').classList.add('active');
}
function closeCompanyModal() {
currentUserId = null;
document.getElementById('companyModal').classList.remove('active');
}
async function confirmAssignCompany() {
if (!currentUserId) return;
const companyId = document.getElementById('companySelect').value || null;
try {
const response = await fetch(`/admin/users/${currentUserId}/assign-company`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ company_id: companyId ? parseInt(companyId) : null })
});
const data = await response.json();
if (data.success) {
closeCompanyModal();
location.reload();
} else {
showMessage(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showMessage('Błąd połączenia', 'error');
}
}
let pwModalUserId = null;
function openPasswordModal(userId, userEmail) {
pwModalUserId = userId;
document.getElementById('pwModalEmail').textContent = userEmail;
document.getElementById('pwNewPassword').value = '';
document.getElementById('pwNewPassword').type = 'password';
document.getElementById('pwLinkResult').style.display = 'none';
document.getElementById('pwLinkActions').style.display = 'flex';
switchPwTab('set');
document.getElementById('passwordModal').classList.add('active');
}
function closePasswordModal() {
pwModalUserId = null;
document.getElementById('passwordModal').classList.remove('active');
}
function switchPwTab(tab) {
const setTab = document.getElementById('pwTabSet');
const linkTab = document.getElementById('pwTabLink');
const setPanel = document.getElementById('pwSetPanel');
const linkPanel = document.getElementById('pwLinkPanel');
if (tab === 'set') {
setTab.className = 'btn btn-primary';
linkTab.className = 'btn btn-secondary';
setPanel.style.display = '';
linkPanel.style.display = 'none';
} else {
setTab.className = 'btn btn-secondary';
linkTab.className = 'btn btn-primary';
setPanel.style.display = 'none';
linkPanel.style.display = '';
}
}
function generatePassword() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*';
const array = new Uint32Array(16);
crypto.getRandomValues(array);
let pw = '';
for (let i = 0; i < 16; i++) {
pw += chars[array[i] % chars.length];
}
const input = document.getElementById('pwNewPassword');
input.value = pw;
input.type = 'text';
}
function togglePwVisibility() {
const input = document.getElementById('pwNewPassword');
input.type = input.type === 'password' ? 'text' : 'password';
}
async function saveDirectPassword() {
if (!pwModalUserId) return;
const password = document.getElementById('pwNewPassword').value.trim();
if (password.length < 8) {
showToast('Hasło musi mieć min. 8 znaków', 'error');
return;
}
try {
const response = await fetch(`/admin/users/${pwModalUserId}/set-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ password })
});
const data = await response.json();
if (data.success) {
showToast('Hasło zostało zmienione', 'success');
closePasswordModal();
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
async function generateResetLink() {
if (!pwModalUserId) return;
try {
const response = await fetch(`/admin/users/${pwModalUserId}/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
document.getElementById('resetUrl').textContent = data.reset_url;
document.getElementById('copyBtnText').textContent = 'Skopiuj link';
document.getElementById('pwLinkResult').style.display = '';
document.getElementById('pwLinkActions').style.display = 'none';
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
function copyResetUrl() {
const url = document.getElementById('resetUrl').textContent;
navigator.clipboard.writeText(url).then(() => {
document.getElementById('copyBtnText').textContent = 'Skopiowano!';
showToast('Link skopiowany do schowka', 'success');
setTimeout(() => {
document.getElementById('copyBtnText').textContent = 'Skopiuj link';
}, 2000);
}).catch(() => {
showToast('Nie udało się skopiować', 'error');
});
}
function deleteUser(userId, userEmail) {
showConfirmModal(
'Usuń użytkownika',
`Czy na pewno chcesz usunąć użytkownika ${userEmail}? Ta operacja jest nieodwracalna!`,
async () => {
try {
const response = await fetch(`/admin/users/${userId}/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
document.querySelector(`tr[data-user-id="${userId}"]`).remove();
showToast(data.message, 'success');
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
},
'danger',
'Usuń',
'btn-danger'
);
}
// Close modals on background click
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', function(e) {
if (e.target === this) {
this.classList.remove('active');
}
});
});
// ========================================
// AI USER CREATION
// ========================================
// AI State
const AIState = {
INPUT: 'input',
PROCESSING: 'processing',
REVIEW: 'review',
CREATING: 'creating',
RESULTS: 'results'
};
let aiCurrentState = AIState.INPUT;
let aiInputType = 'text'; // 'text' or 'image'
let aiSelectedFile = null;
let aiProposedUsers = [];
let aiCreationResults = [];
// Open/Close AI Modal
function openAIUserModal() {
resetAIState();
document.getElementById('aiUserModal').classList.add('active');
}
function closeAIUserModal() {
document.getElementById('aiUserModal').classList.remove('active');
resetAIState();
}
function resetAIState() {
aiCurrentState = AIState.INPUT;
aiInputType = 'text';
aiSelectedFile = null;
aiProposedUsers = [];
aiCreationResults = [];
// Reset UI
document.getElementById('aiUserText').value = '';
removeAIFile();
switchAIInputTab('text');
updateAISteps(1);
showAIStep('input');
document.getElementById('aiBackBtn').style.display = 'none';
document.getElementById('aiNextBtn').textContent = 'Analizuj z AI';
document.getElementById('aiNextBtn').disabled = false;
}
// Tab switching
function switchAIInputTab(type, event) {
aiInputType = type;
document.querySelectorAll('.ai-input-tab').forEach(tab => {
tab.classList.remove('active');
});
if (event && event.target) {
event.target.closest('.ai-input-tab').classList.add('active');
} else {
// Fallback for reset
document.querySelector(`.ai-input-tab:${type === 'text' ? 'first' : 'last'}-child`).classList.add('active');
}
if (type === 'text') {
document.getElementById('aiTextInput').style.display = 'block';
document.getElementById('aiImageInput').style.display = 'none';
} else {
document.getElementById('aiTextInput').style.display = 'none';
document.getElementById('aiImageInput').style.display = 'block';
}
}
// File handling
function handleAIFileSelect(event) {
const file = event.target.files[0];
if (file) {
if (file.size > 5 * 1024 * 1024) {
showToast('Plik jest za duży (max 5MB)', 'error');
return;
}
if (!file.type.startsWith('image/')) {
showToast('Dozwolone tylko obrazy', 'error');
return;
}
aiSelectedFile = file;
displayFilePreview(file);
}
}
function displayFilePreview(file) {
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('aiPreviewImg').src = e.target.result;
document.getElementById('aiFileName').textContent = file.name;
document.getElementById('aiFileSize').textContent = formatFileSize(file.size);
document.getElementById('aiFilePreview').style.display = 'flex';
document.getElementById('aiFileDropzone').style.display = 'none';
};
reader.readAsDataURL(file);
}
function removeAIFile() {
aiSelectedFile = null;
document.getElementById('aiFileInput').value = '';
document.getElementById('aiPreviewImg').src = '';
document.getElementById('aiFilePreview').style.display = 'none';
document.getElementById('aiFileDropzone').style.display = 'block';
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// Drag and drop
const dropzone = document.getElementById('aiFileDropzone');
if (dropzone) {
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
dropzone.classList.add('drag-over');
});
dropzone.addEventListener('dragleave', () => {
dropzone.classList.remove('drag-over');
});
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file) {
document.getElementById('aiFileInput').files = e.dataTransfer.files;
handleAIFileSelect({ target: { files: [file] } });
}
});
}
// Clipboard paste support (Ctrl+V / Cmd+V)
document.addEventListener('paste', (e) => {
// Only handle paste when AI modal is open
const modal = document.getElementById('aiUserModal');
if (!modal.classList.contains('active')) return;
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) {
// Auto-switch to Screenshot tab
aiInputType = 'image';
document.querySelectorAll('.ai-input-tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.ai-input-tab')[1].classList.add('active');
document.getElementById('aiTextInput').style.display = 'none';
document.getElementById('aiImageInput').style.display = 'block';
// Process the pasted image
aiSelectedFile = file;
displayFilePreview(file);
showToast('Obraz wklejony ze schowka', 'success');
}
break;
}
}
});
// Steps UI
function updateAISteps(step) {
for (let i = 1; i <= 3; i++) {
const stepEl = document.getElementById(`aiStep${i}`);
stepEl.classList.remove('active', 'completed');
if (i < step) {
stepEl.classList.add('completed');
} else if (i === step) {
stepEl.classList.add('active');
}
}
}
function showAIStep(step) {
document.getElementById('aiStepInput').style.display = step === 'input' ? 'block' : 'none';
document.getElementById('aiStepReview').style.display = step === 'review' ? 'block' : 'none';
document.getElementById('aiStepResults').style.display = step === 'results' ? 'block' : 'none';
}
// Navigation
function aiGoBack() {
if (aiCurrentState === AIState.REVIEW) {
aiCurrentState = AIState.INPUT;
updateAISteps(1);
showAIStep('input');
document.getElementById('aiBackBtn').style.display = 'none';
document.getElementById('aiNextBtn').textContent = 'Analizuj z AI';
}
}
async function aiGoNext() {
if (aiCurrentState === AIState.INPUT) {
// Validate input
if (aiInputType === 'text') {
const text = document.getElementById('aiUserText').value.trim();
if (!text) {
showToast('Wklej dane użytkowników', 'error');
return;
}
} else {
if (!aiSelectedFile) {
showToast('Wybierz screenshot z danymi', 'error');
return;
}
}
// Move to processing
aiCurrentState = AIState.PROCESSING;
updateAISteps(2);
showAIStep('review');
document.getElementById('aiLoading').style.display = 'flex';
document.getElementById('aiResponse').style.display = 'none';
document.getElementById('aiBackBtn').style.display = 'none';
document.getElementById('aiNextBtn').textContent = 'Przetwarzanie...';
document.getElementById('aiNextBtn').disabled = true;
await processWithAI();
} else if (aiCurrentState === AIState.REVIEW) {
// Get selected users
const selectedUsers = getSelectedUsers();
if (selectedUsers.length === 0) {
showToast('Wybierz co najmniej jednego użytkownika', 'error');
return;
}
// Move to creating
aiCurrentState = AIState.CREATING;
document.getElementById('aiNextBtn').textContent = 'Tworzenie...';
document.getElementById('aiNextBtn').disabled = true;
await createUsers(selectedUsers);
} else if (aiCurrentState === AIState.RESULTS) {
closeAIUserModal();
location.reload();
}
}
// AI Processing
async function processWithAI() {
try {
let response;
if (aiInputType === 'text') {
const text = document.getElementById('aiUserText').value.trim();
response = await fetch('/api/admin/users/ai-parse', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
input_type: 'text',
content: text
})
});
} else {
const formData = new FormData();
formData.append('input_type', 'image');
formData.append('file', aiSelectedFile);
response = await fetch('/api/admin/users/ai-parse', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
},
body: formData
});
}
const data = await response.json();
document.getElementById('aiLoading').style.display = 'none';
document.getElementById('aiResponse').style.display = 'block';
if (data.success) {
aiProposedUsers = data.proposed_users || [];
document.getElementById('aiAnalysisText').textContent = data.ai_response || 'Analiza zakończona.';
// Show duplicates warning
if (data.duplicate_emails && data.duplicate_emails.length > 0) {
document.getElementById('aiDuplicatesWarning').style.display = 'block';
document.getElementById('aiDuplicatesText').textContent =
`Te emaile już istnieją w systemie: ${data.duplicate_emails.join(', ')}`;
} else {
document.getElementById('aiDuplicatesWarning').style.display = 'none';
}
// Render users table
renderUsersTable(aiProposedUsers, data.duplicate_emails || []);
aiCurrentState = AIState.REVIEW;
document.getElementById('aiBackBtn').style.display = 'block';
document.getElementById('aiNextBtn').textContent = 'Utwórz konta';
document.getElementById('aiNextBtn').disabled = false;
} else {
document.getElementById('aiAnalysisText').textContent = data.error || 'Wystąpił błąd podczas analizy.';
document.getElementById('aiBackBtn').style.display = 'block';
document.getElementById('aiNextBtn').textContent = 'Spróbuj ponownie';
document.getElementById('aiNextBtn').disabled = false;
aiCurrentState = AIState.INPUT;
}
} catch (error) {
console.error('AI processing error:', error);
document.getElementById('aiLoading').style.display = 'none';
document.getElementById('aiResponse').style.display = 'block';
document.getElementById('aiAnalysisText').textContent = 'Błąd połączenia z serwerem. Spróbuj ponownie.';
document.getElementById('aiBackBtn').style.display = 'block';
document.getElementById('aiNextBtn').textContent = 'Spróbuj ponownie';
document.getElementById('aiNextBtn').disabled = false;
aiCurrentState = AIState.INPUT;
}
}
function renderUsersTable(users, duplicates) {
const tbody = document.getElementById('aiUsersTableBody');
tbody.innerHTML = '';
if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; color: var(--text-secondary);">Nie znaleziono użytkowników</td></tr>';
return;
}
users.forEach((user, index) => {
const isDuplicate = duplicates.includes(user.email);
const hasWarnings = user.warnings && user.warnings.length > 0;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>
<input type="checkbox" class="ai-user-checkbox"
data-index="${index}"
${isDuplicate ? 'disabled' : 'checked'}>
</td>
<td>
${user.email}
${isDuplicate ? '<span class="ai-duplicate-badge">Duplikat</span>' : ''}
</td>
<td>${user.name || '-'}</td>
<td>${user.company_name || '-'}</td>
<td>${user.role === 'ADMIN' ? 'Tak' : 'Nie'}</td>
<td>
${hasWarnings ? `
<div class="ai-user-warning">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
${user.warnings.join(', ')}
</div>
` : ''}
</td>
`;
tbody.appendChild(tr);
});
}
function getSelectedUsers() {
const selected = [];
document.querySelectorAll('.ai-user-checkbox:checked').forEach(cb => {
const index = parseInt(cb.dataset.index);
if (aiProposedUsers[index]) {
selected.push(aiProposedUsers[index]);
}
});
return selected;
}
// Create Users
async function createUsers(users) {
try {
const response = await fetch('/api/admin/users/bulk-create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ users: users })
});
const data = await response.json();
aiCurrentState = AIState.RESULTS;
updateAISteps(3);
showAIStep('results');
if (data.success) {
aiCreationResults = {
created: data.created || [],
failed: data.failed || []
};
const createdCount = aiCreationResults.created.length;
const failedCount = aiCreationResults.failed.length;
document.getElementById('aiResultsSummary').textContent =
`Utworzono ${createdCount} ${createdCount === 1 ? 'konto' : 'kont'}` +
(failedCount > 0 ? `, ${failedCount} nie udało się utworzyć.` : '.');
renderResults(aiCreationResults);
document.getElementById('aiBackBtn').style.display = 'none';
document.getElementById('aiNextBtn').textContent = 'Zamknij';
document.getElementById('aiNextBtn').disabled = false;
} else {
document.getElementById('aiResultsSummary').textContent = data.error || 'Wystąpił błąd podczas tworzenia kont.';
document.getElementById('aiResultsList').innerHTML = '';
document.getElementById('aiBackBtn').style.display = 'block';
document.getElementById('aiNextBtn').textContent = 'Zamknij';
document.getElementById('aiNextBtn').disabled = false;
}
} catch (error) {
console.error('Create users error:', error);
aiCurrentState = AIState.RESULTS;
updateAISteps(3);
showAIStep('results');
document.getElementById('aiResultsSummary').textContent = 'Błąd połączenia z serwerem.';
document.getElementById('aiResultsList').innerHTML = '';
document.getElementById('aiBackBtn').style.display = 'block';
document.getElementById('aiNextBtn').textContent = 'Zamknij';
document.getElementById('aiNextBtn').disabled = false;
}
}
function renderResults(results) {
const container = document.getElementById('aiResultsList');
container.innerHTML = '';
// Created users
results.created.forEach(user => {
const div = document.createElement('div');
div.className = 'ai-result-item success';
div.innerHTML = `
<svg class="ai-result-icon success" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ai-result-info">
<span class="ai-result-email">${user.email}</span>
<span class="ai-result-password">${user.generated_password}</span>
</div>
<button class="ai-result-copy" onclick="copyPassword('${user.generated_password}', this)">
Kopiuj hasło
</button>
`;
container.appendChild(div);
});
// Failed users
results.failed.forEach(user => {
const div = document.createElement('div');
div.className = 'ai-result-item error';
div.innerHTML = `
<svg class="ai-result-icon error" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ai-result-info">
<span class="ai-result-email">${user.email}</span>
<span style="color: var(--error); font-size: var(--font-size-sm);">${user.error}</span>
</div>
`;
container.appendChild(div);
});
}
function copyPassword(password, button) {
navigator.clipboard.writeText(password).then(() => {
button.textContent = 'Skopiowano!';
setTimeout(() => {
button.textContent = 'Kopiuj hasło';
}, 2000);
}).catch(() => {
showToast('Nie udało się skopiować', 'error');
});
}
{% endblock %}