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
Production moved from on-prem VM 249 (10.22.68.249) to OVH VPS (57.128.200.27, inpi-vps-waw01). Updated ALL documentation, slash commands, memory files, architecture docs, and deploy procedures. Added |local_time Jinja filter (UTC→Europe/Warsaw) and converted 155 .strftime() calls across 71 templates so timestamps display in Polish timezone regardless of server timezone. Also includes: created_by_id tracking, abort import fix, ICS calendar fix for missing end times, Pros Poland data cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
565 lines
20 KiB
HTML
565 lines
20 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; }
|
|
|
|
.pwa-badge {
|
|
display: inline-block;
|
|
padding: 1px 6px;
|
|
border-radius: var(--radius-sm);
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
background: #7C3AED;
|
|
color: white;
|
|
vertical-align: middle;
|
|
margin-left: 4px;
|
|
}
|
|
|
|
/* ---- 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 }}{{ ' (PWA)' if s.is_pwa }}">
|
|
<td>{{ s.user_name }}</td>
|
|
<td data-sort-value="{{ s.started_at|local_time('%Y%m%d%H%M') if s.started_at else '0' }}">{{ s.started_at|local_time('%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 }}{% if s.is_pwa %} <span class="pwa-badge">PWA</span>{% endif %}</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|local_time('%Y%m%d%H%M') if u.last_login else '0' }}">{{ u.last_login|local_time('%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 %}
|