feat(admin): sortable user table + user activity analytics panel
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
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
1. /admin/users — clickable column headers for sorting by ID, name, company, created date, last login. Arrows indicate sort direction. 2. /admin/user-activity — new analytics panel showing: - Summary stats (sessions, unique users, avg duration, pageviews) - Daily active users chart (CSS-only, 30 days) - Recent logins table (user, device, duration, pages) - Most visited pages - Most active users ranking Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
805471fe00
commit
5fa730f5a5
@ -35,3 +35,4 @@ from . import routes_bulk_enrichment # noqa: E402, F401
|
||||
from . import routes_website_discovery # noqa: E402, F401
|
||||
from . import routes_portal_seo # noqa: E402, F401
|
||||
from . import routes_user_insights # noqa: E402, F401
|
||||
from . import routes_user_activity # noqa: E402, F401
|
||||
|
||||
205
blueprints/admin/routes_user_activity.py
Normal file
205
blueprints/admin/routes_user_activity.py
Normal file
@ -0,0 +1,205 @@
|
||||
"""
|
||||
Admin User Activity Routes
|
||||
============================
|
||||
|
||||
User activity dashboard - sessions, logins, page views, active users chart.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required
|
||||
from sqlalchemy import func, desc, cast, Date
|
||||
|
||||
from . import bp
|
||||
from database import (
|
||||
SessionLocal, User, UserSession, PageView, SystemRole
|
||||
)
|
||||
from utils.decorators import role_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route('/user-activity')
|
||||
@login_required
|
||||
@role_required(SystemRole.ADMIN)
|
||||
def admin_user_activity():
|
||||
"""User activity dashboard - sessions, logins, pages, daily active users."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
today = date.today()
|
||||
start_date = today - timedelta(days=30)
|
||||
start_dt = datetime.combine(start_date, datetime.min.time())
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# SUMMARY STATS (last 30 days, non-bot only)
|
||||
# ----------------------------------------------------------
|
||||
summary_row = db.query(
|
||||
func.count(UserSession.id).label('total_sessions'),
|
||||
func.count(func.distinct(UserSession.user_id)).label('unique_users'),
|
||||
func.coalesce(func.avg(UserSession.duration_seconds), 0).label('avg_duration'),
|
||||
).filter(
|
||||
UserSession.started_at >= start_dt,
|
||||
UserSession.is_bot == False,
|
||||
UserSession.user_id.isnot(None),
|
||||
).first()
|
||||
|
||||
total_sessions = summary_row.total_sessions or 0
|
||||
unique_users = summary_row.unique_users or 0
|
||||
avg_duration_sec = int(summary_row.avg_duration or 0)
|
||||
|
||||
total_pageviews = db.query(func.count(PageView.id)).filter(
|
||||
PageView.viewed_at >= start_dt,
|
||||
).scalar() or 0
|
||||
|
||||
summary = {
|
||||
'total_sessions': total_sessions,
|
||||
'unique_users': unique_users,
|
||||
'avg_duration_min': round(avg_duration_sec / 60, 1) if avg_duration_sec else 0,
|
||||
'total_pageviews': total_pageviews,
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# RECENT LOGINS (last 50 sessions with user info)
|
||||
# ----------------------------------------------------------
|
||||
recent_sessions_q = (
|
||||
db.query(
|
||||
UserSession,
|
||||
User.name.label('user_name'),
|
||||
User.email.label('user_email'),
|
||||
)
|
||||
.join(User, UserSession.user_id == User.id)
|
||||
.filter(
|
||||
UserSession.started_at >= start_dt,
|
||||
UserSession.is_bot == False,
|
||||
)
|
||||
.order_by(UserSession.started_at.desc())
|
||||
.limit(50)
|
||||
.all()
|
||||
)
|
||||
|
||||
recent_sessions = []
|
||||
for sess, user_name, user_email in recent_sessions_q:
|
||||
duration_min = round((sess.duration_seconds or 0) / 60, 1)
|
||||
recent_sessions.append({
|
||||
'user_name': user_name or user_email or '?',
|
||||
'started_at': sess.started_at,
|
||||
'device_type': sess.device_type or '-',
|
||||
'browser': sess.browser or '-',
|
||||
'duration_min': duration_min,
|
||||
'page_views_count': sess.page_views_count or 0,
|
||||
})
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# MOST VISITED PAGES (top 20 paths)
|
||||
# ----------------------------------------------------------
|
||||
top_pages_q = (
|
||||
db.query(
|
||||
PageView.path,
|
||||
func.count(PageView.id).label('view_count'),
|
||||
func.coalesce(func.avg(PageView.time_on_page_seconds), 0).label('avg_time'),
|
||||
)
|
||||
.filter(PageView.viewed_at >= start_dt)
|
||||
.group_by(PageView.path)
|
||||
.order_by(desc('view_count'))
|
||||
.limit(20)
|
||||
.all()
|
||||
)
|
||||
|
||||
top_pages = []
|
||||
for row in top_pages_q:
|
||||
top_pages.append({
|
||||
'path': row.path,
|
||||
'view_count': row.view_count,
|
||||
'avg_time_sec': int(row.avg_time or 0),
|
||||
})
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# MOST ACTIVE USERS (top 20 by session count)
|
||||
# ----------------------------------------------------------
|
||||
active_users_q = (
|
||||
db.query(
|
||||
User.id,
|
||||
User.name,
|
||||
User.email,
|
||||
User.last_login,
|
||||
func.count(UserSession.id).label('session_count'),
|
||||
func.coalesce(func.sum(UserSession.duration_seconds), 0).label('total_time'),
|
||||
func.coalesce(func.sum(UserSession.page_views_count), 0).label('total_pages'),
|
||||
)
|
||||
.join(UserSession, UserSession.user_id == User.id)
|
||||
.filter(
|
||||
UserSession.started_at >= start_dt,
|
||||
UserSession.is_bot == False,
|
||||
)
|
||||
.group_by(User.id, User.name, User.email, User.last_login)
|
||||
.order_by(desc('session_count'))
|
||||
.limit(20)
|
||||
.all()
|
||||
)
|
||||
|
||||
active_users = []
|
||||
for row in active_users_q:
|
||||
total_min = round((row.total_time or 0) / 60, 1)
|
||||
active_users.append({
|
||||
'name': row.name or row.email,
|
||||
'session_count': row.session_count,
|
||||
'total_time_min': total_min,
|
||||
'total_pages': row.total_pages or 0,
|
||||
'last_login': row.last_login,
|
||||
})
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# DAILY ACTIVE USERS (last 30 days for chart)
|
||||
# ----------------------------------------------------------
|
||||
dau_q = (
|
||||
db.query(
|
||||
cast(UserSession.started_at, Date).label('day'),
|
||||
func.count(func.distinct(UserSession.user_id)).label('users'),
|
||||
)
|
||||
.filter(
|
||||
UserSession.started_at >= start_dt,
|
||||
UserSession.is_bot == False,
|
||||
UserSession.user_id.isnot(None),
|
||||
)
|
||||
.group_by('day')
|
||||
.order_by('day')
|
||||
.all()
|
||||
)
|
||||
|
||||
# Fill in missing days with 0
|
||||
dau_map = {row.day: row.users for row in dau_q}
|
||||
daily_active = []
|
||||
max_dau = 1
|
||||
for i in range(31):
|
||||
d = start_date + timedelta(days=i)
|
||||
count = dau_map.get(d, 0)
|
||||
if count > max_dau:
|
||||
max_dau = count
|
||||
daily_active.append({
|
||||
'date': d,
|
||||
'label': d.strftime('%d.%m'),
|
||||
'count': count,
|
||||
})
|
||||
|
||||
# Add percentage for CSS bar heights
|
||||
for item in daily_active:
|
||||
item['pct'] = round(item['count'] / max_dau * 100) if max_dau > 0 else 0
|
||||
|
||||
return render_template(
|
||||
'admin/user_activity.html',
|
||||
summary=summary,
|
||||
recent_sessions=recent_sessions,
|
||||
top_pages=top_pages,
|
||||
active_users=active_users,
|
||||
daily_active=daily_active,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"User activity dashboard error: {e}", exc_info=True)
|
||||
flash('Blad ladowania aktywnosci uzytkownikow.', 'error')
|
||||
return redirect(url_for('admin.admin_users'))
|
||||
finally:
|
||||
db.close()
|
||||
336
templates/admin/user_activity.html
Normal file
336
templates/admin/user_activity.html
Normal file
@ -0,0 +1,336 @@
|
||||
{% 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(auto-fit, minmax(180px, 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);
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
/* ---- DAU Chart (CSS-only bars) ---- */
|
||||
.chart-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 180px;
|
||||
padding-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.bar-col {
|
||||
flex: 1;
|
||||
min-width: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 100%;
|
||||
background: var(--primary);
|
||||
border-radius: 3px 3px 0 0;
|
||||
min-height: 2px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.bar:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary);
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
transform: rotate(180deg);
|
||||
height: 40px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.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 }} uzytkownikow">
|
||||
<span class="bar-value">{% if day.count > 0 %}{{ day.count }}{% endif %}</span>
|
||||
<div class="bar" style="height: {{ day.pct }}%;"></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="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Uzytkownik</th>
|
||||
<th>Data</th>
|
||||
<th>Urzadzenie</th>
|
||||
<th>Przegladarka</th>
|
||||
<th>Czas (min)</th>
|
||||
<th>Odslony</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in recent_sessions %}
|
||||
<tr>
|
||||
<td>{{ s.user_name }}</td>
|
||||
<td>{{ 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>
|
||||
{% else %}
|
||||
<p class="text-muted">Brak danych o sesjach w ostatnich 30 dniach.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Most visited pages -->
|
||||
<div class="section">
|
||||
<h2>Najczesciej odwiedzane strony</h2>
|
||||
{% if top_pages %}
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Sciezka</th>
|
||||
<th>Odslony</th>
|
||||
<th>Sredni czas (s)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in top_pages %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td><code>{{ p.path }}</code></td>
|
||||
<td class="num">{{ p.view_count }}</td>
|
||||
<td class="num">{{ p.avg_time_sec }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Brak danych o odslonach stron.</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 %}
|
||||
@ -1076,6 +1076,36 @@
|
||||
.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 %}
|
||||
|
||||
@ -1147,13 +1177,13 @@
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Użytkownik</th>
|
||||
<th>Firma</th>
|
||||
<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>Upr. firmowe</th>
|
||||
<th>Rola</th>
|
||||
<th>Utworzono</th>
|
||||
<th>Ostatnie logowanie</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>
|
||||
@ -1208,10 +1238,12 @@
|
||||
<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);">
|
||||
<td style="font-size: var(--font-size-sm); color: var(--text-secondary);"
|
||||
data-sort-value="{{ user.created_at.strftime('%Y%m%d%H%M') }}">
|
||||
{{ user.created_at.strftime('%d.%m.%Y %H:%M') }}
|
||||
</td>
|
||||
<td style="font-size: var(--font-size-sm); color: var(--text-secondary);">
|
||||
<td style="font-size: var(--font-size-sm); color: var(--text-secondary);"
|
||||
data-sort-value="{{ user.last_login.strftime('%Y%m%d%H%M') if user.last_login else '0' }}">
|
||||
{% if user.last_login %}
|
||||
{{ user.last_login.strftime('%d.%m.%Y %H:%M') }}
|
||||
{% else %}
|
||||
@ -2875,4 +2907,77 @@ Lub format CSV, Excel, lista emaili..."></textarea>
|
||||
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 %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user