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
The sort direction check was done AFTER clearing all sort classes, so it always read false and defaulted to descending. Now reads the current state before clearing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
553 lines
19 KiB
HTML
553 lines
19 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Aktywnosc Uzytkownikow - Admin - Norda Biznes Partner{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.admin-header {
|
|
margin-bottom: var(--spacing-xl);
|
|
}
|
|
|
|
.admin-header h1 {
|
|
font-size: var(--font-size-3xl);
|
|
color: var(--text-primary);
|
|
margin: 0;
|
|
}
|
|
|
|
.admin-header p {
|
|
margin: var(--spacing-xs) 0 0 0;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: var(--spacing-md);
|
|
margin-bottom: var(--spacing-lg);
|
|
}
|
|
|
|
.stat-card {
|
|
background: var(--surface);
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
border-radius: var(--radius);
|
|
box-shadow: var(--shadow);
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: var(--font-size-xl);
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.stat-label {
|
|
color: var(--text-secondary);
|
|
font-size: 0.75rem;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.data-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.data-table th,
|
|
.data-table td {
|
|
padding: var(--spacing-md);
|
|
text-align: left;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.data-table th {
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
font-size: var(--font-size-sm);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.data-table tr:hover {
|
|
background: var(--background);
|
|
}
|
|
|
|
.data-table td.num {
|
|
text-align: right;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.device-badge {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: var(--radius-sm);
|
|
font-size: var(--font-size-xs);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.device-desktop { background: #DBEAFE; color: #1D4ED8; }
|
|
.device-mobile { background: #D1FAE5; color: #065F46; }
|
|
.device-tablet { background: #FEF3C7; color: #D97706; }
|
|
.device-other { background: #F3F4F6; color: #6B7280; }
|
|
|
|
/* ---- Group tabs ---- */
|
|
.group-tabs {
|
|
display: flex;
|
|
gap: var(--spacing-xs);
|
|
margin-bottom: var(--spacing-md);
|
|
flex-wrap: wrap;
|
|
}
|
|
.group-tab {
|
|
padding: 6px 16px;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
background: var(--surface);
|
|
color: var(--text-secondary);
|
|
font-size: var(--font-size-sm);
|
|
cursor: pointer;
|
|
transition: var(--transition);
|
|
}
|
|
.group-tab:hover { border-color: var(--primary); color: var(--primary); }
|
|
.group-tab.active { background: var(--primary); color: white; border-color: var(--primary); }
|
|
|
|
/* ---- Sortable table headers ---- */
|
|
.sortable { cursor: pointer; user-select: none; white-space: nowrap; }
|
|
.sortable::after { content: ' ⇅'; opacity: 0.3; font-size: 0.8em; }
|
|
.sortable.sort-asc::after { content: ' ▲'; opacity: 0.7; }
|
|
.sortable.sort-desc::after { content: ' ▼'; opacity: 0.7; }
|
|
.sortable:hover { color: var(--primary); }
|
|
|
|
/* ---- DAU Chart (CSS-only bars) ---- */
|
|
.chart-container {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.bar-chart {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 3px;
|
|
height: 250px;
|
|
padding-top: var(--spacing-sm);
|
|
}
|
|
|
|
.bar-col {
|
|
flex: 1;
|
|
min-width: 18px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
height: 100%;
|
|
position: relative;
|
|
}
|
|
|
|
.bar-col.weekend .bar {
|
|
background: linear-gradient(to top, #94a3b8, #b0bec5);
|
|
}
|
|
.bar-col.weekend .bar-label {
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.bar-col.monday-start {
|
|
border-left: 1px dashed #cbd5e1;
|
|
margin-left: 2px;
|
|
padding-left: 2px;
|
|
}
|
|
|
|
.bar-value {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
line-height: 1;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.bar-event {
|
|
font-size: 8px;
|
|
color: #dc2626;
|
|
font-weight: 600;
|
|
line-height: 1;
|
|
margin-bottom: 2px;
|
|
white-space: nowrap;
|
|
max-width: 40px;
|
|
overflow: visible;
|
|
}
|
|
|
|
.bar-wrapper {
|
|
width: 100%;
|
|
height: 160px;
|
|
display: flex;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.bar {
|
|
width: 100%;
|
|
background: linear-gradient(to top, var(--primary), #4a7cc9);
|
|
border-radius: 3px 3px 0 0;
|
|
min-height: 4px;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.bar:hover {
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.bar-label {
|
|
font-size: 10px;
|
|
color: var(--text-secondary);
|
|
writing-mode: vertical-rl;
|
|
text-orientation: mixed;
|
|
transform: rotate(180deg);
|
|
height: 45px;
|
|
line-height: 1;
|
|
margin-top: 6px;
|
|
}
|
|
|
|
.text-muted {
|
|
color: var(--text-secondary);
|
|
font-size: var(--font-size-sm);
|
|
}
|
|
|
|
.table-scroll {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.stats-grid {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
|
|
.bar-chart {
|
|
min-width: 600px;
|
|
}
|
|
|
|
.data-table th,
|
|
.data-table td {
|
|
padding: var(--spacing-sm);
|
|
font-size: var(--font-size-sm);
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container">
|
|
|
|
<!-- Header -->
|
|
<div class="admin-header">
|
|
<h1>Aktywnosc uzytkownikow</h1>
|
|
<p>Dane z ostatnich 30 dni (bez botow)</p>
|
|
</div>
|
|
|
|
<!-- Summary stat cards -->
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ summary.total_sessions }}</div>
|
|
<div class="stat-label">Sesje</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ summary.unique_users }}</div>
|
|
<div class="stat-label">Unikalni uzytkownicy</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ summary.avg_duration_min }} min</div>
|
|
<div class="stat-label">Sredni czas sesji</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ summary.total_pageviews }}</div>
|
|
<div class="stat-label">Odslony stron</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Daily Active Users chart -->
|
|
<div class="section">
|
|
<h2>Aktywni uzytkownicy dziennie</h2>
|
|
<div class="chart-container">
|
|
<div class="bar-chart">
|
|
{% for day in daily_active %}
|
|
<div class="bar-col{{ ' weekend' if day.is_weekend }}{{ ' monday-start' if day.is_monday }}" title="{{ day.label }}: {{ day.count }} użytkowników{% if day.event %} | {{ day.event.title }} ({{ day.event.attendees }} zapisanych){% endif %}">
|
|
<span class="bar-value">{% if day.count > 0 %}{{ day.count }}{% endif %}</span>
|
|
{% if day.event %}
|
|
<span class="bar-event">📅({{ day.event.attendees }})</span>
|
|
{% endif %}
|
|
<div class="bar-wrapper">
|
|
<div class="bar" style="height: {{ [day.pct, 5]|max if day.count > 0 else 0 }}%;"></div>
|
|
</div>
|
|
<span class="bar-label">{{ day.label }}</span>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent logins -->
|
|
<div class="section">
|
|
<h2>Ostatnie logowania</h2>
|
|
{% if recent_sessions %}
|
|
<div class="group-tabs">
|
|
<button class="group-tab active" data-group="all">Wszystkie</button>
|
|
<button class="group-tab" data-group="user">Wg użytkownika</button>
|
|
<button class="group-tab" data-group="device">Wg urządzenia</button>
|
|
<button class="group-tab" data-group="browser">Wg przeglądarki</button>
|
|
</div>
|
|
|
|
<!-- Detail table -->
|
|
<div id="logins-detail" class="table-scroll">
|
|
<table class="data-table" id="logins-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="sortable" data-col="0" data-type="string">Użytkownik</th>
|
|
<th class="sortable sort-desc" data-col="1" data-type="date">Data</th>
|
|
<th class="sortable" data-col="2" data-type="string">Urządzenie</th>
|
|
<th class="sortable" data-col="3" data-type="string">Przeglądarka</th>
|
|
<th class="sortable" data-col="4" data-type="number">Czas (min)</th>
|
|
<th class="sortable" data-col="5" data-type="number">Odsłony</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for s in recent_sessions %}
|
|
<tr data-user="{{ s.user_name }}" data-device="{{ s.device_type }}" data-browser="{{ s.browser }}">
|
|
<td>{{ s.user_name }}</td>
|
|
<td data-sort-value="{{ s.started_at.strftime('%Y%m%d%H%M') if s.started_at else '0' }}">{{ s.started_at.strftime('%d.%m.%Y %H:%M') if s.started_at else '-' }}</td>
|
|
<td>
|
|
{% set dt = s.device_type|lower %}
|
|
<span class="device-badge device-{{ dt if dt in ['desktop','mobile','tablet'] else 'other' }}">
|
|
{{ s.device_type }}
|
|
</span>
|
|
</td>
|
|
<td>{{ s.browser }}</td>
|
|
<td class="num">{{ s.duration_min }}</td>
|
|
<td class="num">{{ s.page_views_count }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Summary table (shown when grouped) -->
|
|
<div id="logins-summary" class="table-scroll" style="display:none;">
|
|
<table class="data-table" id="summary-table">
|
|
<thead><tr id="summary-header"></tr></thead>
|
|
<tbody id="summary-body"></tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<p class="text-muted">Brak danych o sesjach w ostatnich 30 dniach.</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Most active users -->
|
|
<div class="section">
|
|
<h2>Najbardziej aktywni uzytkownicy</h2>
|
|
{% if active_users %}
|
|
<div class="table-scroll">
|
|
<table class="data-table" id="active-users-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="sortable" data-col="0" data-type="number">#</th>
|
|
<th class="sortable" data-col="1" data-type="string">Użytkownik</th>
|
|
<th class="sortable sort-desc" data-col="2" data-type="number">Sesje</th>
|
|
<th class="sortable" data-col="3" data-type="number">Łączny czas (min)</th>
|
|
<th class="sortable" data-col="4" data-type="number">Odsłony</th>
|
|
<th class="sortable" data-col="5" data-type="date">Ostatnie logowanie</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for u in active_users %}
|
|
<tr>
|
|
<td class="num">{{ loop.index }}</td>
|
|
<td>{{ u.name }}</td>
|
|
<td class="num">{{ u.session_count }}</td>
|
|
<td class="num">{{ u.total_time_min }}</td>
|
|
<td class="num">{{ u.total_pages }}</td>
|
|
<td data-sort-value="{{ u.last_login.strftime('%Y%m%d%H%M') if u.last_login else '0' }}">{{ u.last_login.strftime('%d.%m.%Y %H:%M') if u.last_login else '-' }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<p class="text-muted">Brak danych o aktywnosci uzytkownikow.</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
/* --- Logins table: sorting + grouping --- */
|
|
(function() {
|
|
var table = document.getElementById('logins-table');
|
|
if (!table) return;
|
|
var tbody = table.querySelector('tbody');
|
|
var rows = Array.from(tbody.querySelectorAll('tr'));
|
|
|
|
/* Column sorting */
|
|
table.querySelectorAll('th.sortable').forEach(function(th) {
|
|
th.addEventListener('click', function() {
|
|
var col = parseInt(th.dataset.col);
|
|
var type = th.dataset.type;
|
|
var isDesc = th.classList.contains('sort-desc');
|
|
var dir = isDesc ? 1 : -1;
|
|
|
|
table.querySelectorAll('th.sortable').forEach(function(h) {
|
|
h.classList.remove('sort-asc', 'sort-desc');
|
|
});
|
|
th.classList.add(isDesc ? 'sort-asc' : 'sort-desc');
|
|
|
|
rows.sort(function(a, b) {
|
|
var aCell = a.cells[col], bCell = b.cells[col];
|
|
var aVal, bVal;
|
|
if (type === 'date') {
|
|
aVal = aCell.dataset.sortValue || '0';
|
|
bVal = bCell.dataset.sortValue || '0';
|
|
} else if (type === 'number') {
|
|
aVal = parseFloat(aCell.textContent) || 0;
|
|
bVal = parseFloat(bCell.textContent) || 0;
|
|
return (aVal - bVal) * dir;
|
|
} else {
|
|
aVal = aCell.textContent.trim().toLowerCase();
|
|
bVal = bCell.textContent.trim().toLowerCase();
|
|
}
|
|
return aVal < bVal ? dir : aVal > bVal ? -dir : 0;
|
|
});
|
|
rows.forEach(function(r) { tbody.appendChild(r); });
|
|
});
|
|
});
|
|
|
|
/* Group tabs */
|
|
var detailDiv = document.getElementById('logins-detail');
|
|
var summaryDiv = document.getElementById('logins-summary');
|
|
var summaryHeader = document.getElementById('summary-header');
|
|
var summaryBody = document.getElementById('summary-body');
|
|
|
|
document.querySelectorAll('.group-tab').forEach(function(tab) {
|
|
tab.addEventListener('click', function() {
|
|
document.querySelectorAll('.group-tab').forEach(function(t) { t.classList.remove('active'); });
|
|
tab.classList.add('active');
|
|
var group = tab.dataset.group;
|
|
|
|
if (group === 'all') {
|
|
detailDiv.style.display = '';
|
|
summaryDiv.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
detailDiv.style.display = 'none';
|
|
summaryDiv.style.display = '';
|
|
|
|
var labels = { user: 'Użytkownik', device: 'Urządzenie', browser: 'Przeglądarka' };
|
|
var cols = [
|
|
{ key: 'name', label: labels[group], type: 'string' },
|
|
{ key: 'count', label: 'Sesje', type: 'number' },
|
|
{ key: 'duration', label: 'Łączny czas (min)', type: 'number' },
|
|
{ key: 'views', label: 'Łączne odsłony', type: 'number' }
|
|
];
|
|
|
|
summaryHeader.innerHTML = '';
|
|
cols.forEach(function(c, i) {
|
|
var th = document.createElement('th');
|
|
th.textContent = c.label;
|
|
th.className = 'sortable' + (i === 1 ? ' sort-desc' : '');
|
|
th.dataset.skey = c.key;
|
|
th.dataset.stype = c.type;
|
|
th.addEventListener('click', function() { sortSummary(th); });
|
|
summaryHeader.appendChild(th);
|
|
});
|
|
|
|
var grouped = {};
|
|
rows.forEach(function(r) {
|
|
var key = r.dataset[group] || '-';
|
|
if (!grouped[key]) grouped[key] = { name: key, count: 0, duration: 0, views: 0 };
|
|
grouped[key].count++;
|
|
grouped[key].duration += parseFloat(r.cells[4].textContent) || 0;
|
|
grouped[key].views += parseInt(r.cells[5].textContent) || 0;
|
|
});
|
|
|
|
var summaryData = Object.values(grouped);
|
|
renderSummary(summaryData, 'count', -1);
|
|
|
|
function sortSummary(th) {
|
|
var key = th.dataset.skey;
|
|
var type = th.dataset.stype;
|
|
var wasDesc = th.classList.contains('sort-desc');
|
|
summaryHeader.querySelectorAll('th').forEach(function(h) { h.classList.remove('sort-asc','sort-desc'); });
|
|
var desc = !wasDesc;
|
|
th.classList.add(desc ? 'sort-desc' : 'sort-asc');
|
|
var dir = desc ? -1 : 1;
|
|
renderSummary(summaryData, key, dir);
|
|
}
|
|
|
|
function renderSummary(data, sortKey, dir) {
|
|
data.sort(function(a, b) {
|
|
var av = a[sortKey], bv = b[sortKey];
|
|
if (typeof av === 'string') { av = av.toLowerCase(); bv = bv.toLowerCase(); }
|
|
return av < bv ? dir : av > bv ? -dir : 0;
|
|
});
|
|
summaryBody.innerHTML = '';
|
|
data.forEach(function(g) {
|
|
var tr = document.createElement('tr');
|
|
tr.innerHTML = '<td><strong>' + g.name + '</strong></td>'
|
|
+ '<td class="num">' + g.count + '</td>'
|
|
+ '<td class="num">' + g.duration.toFixed(1) + '</td>'
|
|
+ '<td class="num">' + g.views + '</td>';
|
|
summaryBody.appendChild(tr);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
})();
|
|
|
|
/* --- Active users table: sorting --- */
|
|
(function() {
|
|
var table = document.getElementById('active-users-table');
|
|
if (!table) return;
|
|
var tbody = table.querySelector('tbody');
|
|
var rows = Array.from(tbody.querySelectorAll('tr'));
|
|
|
|
table.querySelectorAll('th.sortable').forEach(function(th) {
|
|
th.addEventListener('click', function() {
|
|
var col = parseInt(th.dataset.col);
|
|
var type = th.dataset.type;
|
|
var isDesc = th.classList.contains('sort-desc');
|
|
var dir = isDesc ? 1 : -1;
|
|
|
|
table.querySelectorAll('th.sortable').forEach(function(h) {
|
|
h.classList.remove('sort-asc', 'sort-desc');
|
|
});
|
|
th.classList.add(isDesc ? 'sort-asc' : 'sort-desc');
|
|
|
|
rows.sort(function(a, b) {
|
|
var aCell = a.cells[col], bCell = b.cells[col];
|
|
if (type === 'date') {
|
|
var av = aCell.dataset.sortValue || '0';
|
|
var bv = bCell.dataset.sortValue || '0';
|
|
return av < bv ? dir : av > bv ? -dir : 0;
|
|
} else if (type === 'number') {
|
|
return (parseFloat(aCell.textContent) - parseFloat(bCell.textContent)) * dir;
|
|
} else {
|
|
var av = aCell.textContent.trim().toLowerCase();
|
|
var bv = bCell.textContent.trim().toLowerCase();
|
|
return av < bv ? dir : av > bv ? -dir : 0;
|
|
}
|
|
});
|
|
rows.forEach(function(r) { tbody.appendChild(r); });
|
|
});
|
|
});
|
|
})();
|
|
{% endblock %}
|