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 sort keys and data-sort-value attributes to 'Upr. firmowe' and 'Rola' columns - Add filter tabs for MANAGER, OFFICE_MANAGER, company-role NONE and MANAGER - Add data-company-role attribute to user rows for JS filtering - Grant OFFICE_MANAGER access to admin_users, assign-company, reset-password, change-role, get-roles endpoints Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3036 lines
110 KiB
HTML
3036 lines
110 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: grid;
|
|
grid-template-columns: repeat(4, 32px);
|
|
gap: 4px;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
/* Sortable table headers */
|
|
.users-table th[data-sort-key] {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
position: relative;
|
|
padding-right: calc(var(--spacing-md) + 16px);
|
|
}
|
|
|
|
.users-table th[data-sort-key]:hover {
|
|
color: var(--primary);
|
|
}
|
|
|
|
.users-table th[data-sort-key]::after {
|
|
content: '⇅';
|
|
position: absolute;
|
|
right: var(--spacing-sm);
|
|
opacity: 0.3;
|
|
font-size: var(--font-size-xs);
|
|
}
|
|
|
|
.users-table th[data-sort-key].sort-asc::after {
|
|
content: '▲';
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.users-table th[data-sort-key].sort-desc::after {
|
|
content: '▼';
|
|
opacity: 0.8;
|
|
}
|
|
</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 %}
|
|
{% if locked_count > 0 %}
|
|
<button class="filter-tab" data-filter="locked" style="color: #DC2626;">Zablokowane ({{ locked_count }})</button>
|
|
{% endif %}
|
|
<span style="border-left:1px solid var(--border);margin:0 4px;"></span>
|
|
<button class="filter-tab" data-filter="role-manager">Kadra zarządz.</button>
|
|
<button class="filter-tab" data-filter="role-office">Kier. Biura</button>
|
|
<button class="filter-tab" data-filter="company-role-none">Bez upr. firm.</button>
|
|
<button class="filter-tab" data-filter="company-role-manager">Zarządzający</button>
|
|
</div>
|
|
|
|
{% if users %}
|
|
<table class="users-table">
|
|
<thead>
|
|
<tr>
|
|
<th data-sort-key="id" data-sort-type="number">ID</th>
|
|
<th data-sort-key="name" data-sort-type="string">Użytkownik</th>
|
|
<th data-sort-key="company" data-sort-type="string">Firma</th>
|
|
<th data-sort-key="company_role" data-sort-type="number">Upr. firmowe</th>
|
|
<th data-sort-key="role" data-sort-type="number">Rola</th>
|
|
<th data-sort-key="created" data-sort-type="date" class="sort-desc">Utworzono</th>
|
|
<th data-sort-key="last_login" data-sort-type="date">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-company-role="{{ user.company_role or 'NONE' }}"
|
|
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' }}"
|
|
data-locked="{{ 'true' if user.locked_until and user.locked_until > now 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 data-sort-value="{{ {'NONE':0,'VIEWER':10,'EMPLOYEE':20,'MANAGER':30}.get(user.company_role, 0) }}">
|
|
{% 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 data-sort-value="{{ {'UNAFFILIATED':10,'MEMBER':20,'EMPLOYEE':30,'MANAGER':40,'OFFICE_MANAGER':50,'ADMIN':100}.get(user.role, 0) }}">
|
|
<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);"
|
|
data-sort-value="{{ user.created_at|local_time('%Y%m%d%H%M') }}">
|
|
{{ user.created_at|local_time }}
|
|
{% if user.created_by_id and creators_map.get(user.created_by_id) %}
|
|
<br><span style="font-size: 0.75rem; color: var(--text-muted, #9CA3AF);" title="Dodany przez {{ creators_map[user.created_by_id] }}">dodał: {{ creators_map[user.created_by_id] }}</span>
|
|
{% else %}
|
|
<br><span style="font-size: 0.75rem; color: var(--text-muted, #9CA3AF);">samorejestracja</span>
|
|
{% endif %}
|
|
</td>
|
|
<td style="font-size: var(--font-size-sm); color: var(--text-secondary);"
|
|
data-sort-value="{{ user.last_login|local_time('%Y%m%d%H%M') if user.last_login else '0' }}">
|
|
{% if user.last_login %}
|
|
{{ user.last_login|local_time }}
|
|
{% else %}
|
|
<span style="color: var(--text-muted, #9CA3AF);">Nigdy</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if user.locked_until and user.locked_until > now %}
|
|
{% set remaining = (user.locked_until - now).total_seconds() %}
|
|
<span class="badge" style="background: #FEE2E2; color: #DC2626; font-weight: 600; cursor: pointer;"
|
|
onclick="unlockUser({{ user.id }}, '{{ user.email|e }}')"
|
|
title="Kliknij aby odblokować">
|
|
Zablokowane ({{ (remaining / 60)|int }} min)
|
|
</span>
|
|
{% elif user.failed_login_attempts and user.failed_login_attempts > 0 %}
|
|
<span class="badge" style="background: #FEF3C7; color: #D97706;"
|
|
title="{{ user.failed_login_attempts }} nieudanych prób logowania">
|
|
{{ user.failed_login_attempts }}x błędne hasło
|
|
</span>
|
|
{% endif %}
|
|
{% 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;">
|
|
Wyślij użytkownikowi email z linkiem do samodzielnego ustawienia hasła, lub wygeneruj link do ręcznego przekazania.
|
|
</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 24 godziny</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 id="pwEmailSent" style="display: none;">
|
|
<div class="reset-url-container">
|
|
<div class="reset-url-info" style="color: var(--color-success, #16a34a);">
|
|
<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>
|
|
<span id="pwEmailSentText">Email wysłany</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer" id="pwLinkActions">
|
|
<button class="btn btn-secondary" onclick="closePasswordModal()">Anuluj</button>
|
|
<button class="btn btn-primary" onclick="sendResetEmail()">
|
|
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="width: 16px; height: 16px; margin-right: 4px;">
|
|
<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>
|
|
Wyślij email
|
|
</button>
|
|
<button class="btn btn-secondary" onclick="generateResetLink()">Tylko 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;
|
|
else if (filter === 'locked') show = row.dataset.locked === 'true';
|
|
else if (filter === 'role-manager') show = row.dataset.role === 'MANAGER';
|
|
else if (filter === 'role-office') show = row.dataset.role === 'OFFICE_MANAGER';
|
|
else if (filter === 'company-role-none') show = row.dataset.companyRole === 'NONE' && row.querySelector('.user-company');
|
|
else if (filter === 'company-role-manager') show = row.dataset.companyRole === 'MANAGER';
|
|
|
|
row.style.display = show ? '' : 'none';
|
|
});
|
|
});
|
|
});
|
|
|
|
async function unlockUser(userId, email) {
|
|
if (!confirm(`Odblokować konto ${email}?`)) return;
|
|
try {
|
|
const response = await fetch(`/admin/users/${userId}/unlock`, {
|
|
method: 'POST',
|
|
headers: {'X-CSRFToken': document.querySelector('meta[name="csrf-token"]')?.content || ''}
|
|
});
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
showNotification(data.message, 'success');
|
|
setTimeout(() => location.reload(), 1000);
|
|
} else {
|
|
showNotification(data.error || 'Błąd odblokowania', 'error');
|
|
}
|
|
} catch (e) {
|
|
showNotification('Błąd połączenia', 'error');
|
|
}
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
function sendRoleNotification(userId, userName, userEmail) {
|
|
showConfirmModal(
|
|
'Wyślij powiadomienie',
|
|
`Wysłać email z podsumowaniem uprawnień do ${userName} (${userEmail})?`,
|
|
async () => {
|
|
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) {
|
|
showToast(data.message, 'success');
|
|
} else {
|
|
showToast(data.error || 'Błąd wysyłania', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Błąd połączenia', 'error');
|
|
}
|
|
},
|
|
'success',
|
|
'Wyślij',
|
|
'btn-primary'
|
|
);
|
|
}
|
|
|
|
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;
|
|
|
|
let pwModalUserEmail = null;
|
|
|
|
function openPasswordModal(userId, userEmail) {
|
|
pwModalUserId = userId;
|
|
pwModalUserEmail = userEmail;
|
|
document.getElementById('pwModalEmail').textContent = userEmail;
|
|
document.getElementById('pwNewPassword').value = '';
|
|
document.getElementById('pwNewPassword').type = 'password';
|
|
document.getElementById('pwLinkResult').style.display = 'none';
|
|
document.getElementById('pwEmailSent').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 sendResetEmail() {
|
|
if (!pwModalUserId) return;
|
|
try {
|
|
const response = await fetch(`/admin/users/${pwModalUserId}/reset-password`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken
|
|
},
|
|
body: JSON.stringify({ send_email: true })
|
|
});
|
|
const data = await response.json();
|
|
if (data.success && data.email_sent) {
|
|
document.getElementById('pwEmailSentText').textContent = `Email wysłany na ${pwModalUserEmail}`;
|
|
document.getElementById('pwEmailSent').style.display = '';
|
|
document.getElementById('pwLinkActions').style.display = 'none';
|
|
showToast(`Email z linkiem do resetu wysłany na ${pwModalUserEmail}`, 'success');
|
|
} else if (data.success && !data.email_sent) {
|
|
showToast('Nie udało się wysłać emaila. Link wygenerowany — skopiuj ręcznie.', 'error');
|
|
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');
|
|
}
|
|
}
|
|
|
|
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');
|
|
});
|
|
}
|
|
|
|
// Column sorting
|
|
(function() {
|
|
const table = document.querySelector('.users-table');
|
|
if (!table) return;
|
|
const tbody = table.querySelector('tbody');
|
|
const headers = table.querySelectorAll('th[data-sort-key]');
|
|
|
|
// Column index map
|
|
const colIndex = {};
|
|
table.querySelectorAll('thead th').forEach((th, i) => {
|
|
if (th.dataset.sortKey) colIndex[th.dataset.sortKey] = i;
|
|
});
|
|
|
|
function getSortValue(row, key, type) {
|
|
const cell = row.children[colIndex[key]];
|
|
if (type === 'date') {
|
|
return cell.dataset.sortValue || '0';
|
|
}
|
|
if (type === 'number') {
|
|
return parseFloat(cell.textContent.trim()) || 0;
|
|
}
|
|
// string — for 'name' use .user-name, for 'company' use link or dash
|
|
if (key === 'name') {
|
|
const el = cell.querySelector('.user-name');
|
|
return (el ? el.textContent : cell.textContent).trim().toLowerCase();
|
|
}
|
|
if (key === 'company') {
|
|
const el = cell.querySelector('.user-company');
|
|
return el ? el.textContent.trim().toLowerCase() : 'zzz'; // push empty to end
|
|
}
|
|
return cell.textContent.trim().toLowerCase();
|
|
}
|
|
|
|
function sortTable(key, type, direction) {
|
|
const rows = Array.from(tbody.querySelectorAll('tr[data-user-id]'));
|
|
rows.sort((a, b) => {
|
|
const va = getSortValue(a, key, type);
|
|
const vb = getSortValue(b, key, type);
|
|
let cmp;
|
|
if (type === 'number') {
|
|
cmp = va - vb;
|
|
} else {
|
|
cmp = va < vb ? -1 : va > vb ? 1 : 0;
|
|
}
|
|
return direction === 'asc' ? cmp : -cmp;
|
|
});
|
|
rows.forEach(row => tbody.appendChild(row));
|
|
}
|
|
|
|
headers.forEach(th => {
|
|
th.addEventListener('click', function() {
|
|
const key = this.dataset.sortKey;
|
|
const type = this.dataset.sortType;
|
|
|
|
// Toggle direction
|
|
let dir;
|
|
if (this.classList.contains('sort-asc')) {
|
|
dir = 'desc';
|
|
} else if (this.classList.contains('sort-desc')) {
|
|
dir = 'asc';
|
|
} else {
|
|
dir = (type === 'date' || type === 'number') ? 'desc' : 'asc';
|
|
}
|
|
|
|
// Clear all sort indicators
|
|
headers.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
|
|
this.classList.add('sort-' + dir);
|
|
|
|
sortTable(key, type, dir);
|
|
});
|
|
});
|
|
})();
|
|
{% endblock %}
|