nordabiz/templates/admin/user_activity.html
Maciej Pienczyn 1a160add8c
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
feat(admin): sortable columns in grouped summary tables (user/device/browser)
Each group-by view now has clickable column headers with sort arrows,
allowing sorting by name, sessions, total duration, and total pageviews.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 08:54:32 +01:00

486 lines
16 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%;
}
.bar-value {
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
line-height: 1;
margin-bottom: 4px;
}
.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" title="{{ day.label }}: {{ day.count }} użytkowników">
<span class="bar-value">{% if day.count > 0 %}{{ day.count }}{% endif %}</span>
<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">
<thead>
<tr>
<th>#</th>
<th>Uzytkownik</th>
<th>Sesje</th>
<th>Laczny czas (min)</th>
<th>Odslony</th>
<th>Ostatnie logowanie</th>
</tr>
</thead>
<tbody>
{% for u in active_users %}
<tr>
<td>{{ 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>{{ 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) {
summaryHeader.querySelectorAll('th').forEach(function(h) { h.classList.remove('sort-asc','sort-desc'); });
var key = th.dataset.skey;
var type = th.dataset.stype;
var desc = !th.classList.contains('sort-desc');
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);
});
}
});
});
})();
{% endblock %}