nordabiz/templates/admin/user_insights.html
Maciej Pienczyn 42e42c5fa0
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: simplify Chat tab - topic categories and cleaner feedback view
Replace anonymized metadata ("45 chars") with real topic categories
(O firmach, Szukanie kontaktu, O wydarzeniach, etc). Remove empty
conversion stats, UTM sources, avg message length. Keep feedback
with ratings in compact two-column layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 04:05:15 +01:00

1123 lines
59 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Analityka - Admin{% endblock %}
{% block head_extra %}
{% if tab == 'overview' %}
<script src="{{ url_for('static', filename='js/vendor/chart.min.js') }}"></script>
{% endif %}
{% endblock %}
{% block extra_css %}
<style>
.insights-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: var(--spacing-md); margin-bottom: var(--spacing-lg); }
.insights-header h1 { font-size: var(--font-size-2xl); font-weight: 700; }
.insights-controls { display: flex; gap: var(--spacing-sm); align-items: center; flex-wrap: wrap; }
/* Period tabs */
.period-tabs { display: flex; gap: var(--spacing-xs); background: white; padding: var(--spacing-xs); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
.period-tab { padding: var(--spacing-sm) var(--spacing-md); border: none; background: transparent; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--font-size-sm); color: var(--text-secondary); transition: var(--transition); text-decoration: none; }
.period-tab.active { background: var(--primary); color: white; font-weight: 500; }
.period-tab:hover:not(.active) { background: var(--background); }
/* Main tabs */
.tabs { display: flex; gap: var(--spacing-xs); margin-bottom: var(--spacing-lg); background: white; padding: var(--spacing-xs); border-radius: var(--radius); box-shadow: var(--shadow-sm); flex-wrap: wrap; }
.tab-link { padding: var(--spacing-sm) var(--spacing-md); border: none; background: transparent; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--font-size-sm); font-weight: 500; color: var(--text-secondary); transition: var(--transition); text-decoration: none; white-space: nowrap; }
.tab-link.active { background: var(--primary); color: white; }
.tab-link:hover:not(.active) { background: var(--background); }
/* Stat cards */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--spacing-md); margin-bottom: var(--spacing-xl); }
.stat-card { background: white; padding: var(--spacing-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
.stat-card.error { border-left: 4px solid var(--error); }
.stat-card.warning { border-left: 4px solid #f59e0b; }
.stat-card.info { border-left: 4px solid #3b82f6; }
.stat-card.success { border-left: 4px solid var(--success); }
.stat-value { font-size: var(--font-size-2xl); font-weight: 700; color: var(--text-primary); }
.stat-label { color: var(--text-secondary); font-size: var(--font-size-sm); margin-top: var(--spacing-xs); }
/* Section cards */
.section-card { background: white; padding: var(--spacing-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); margin-bottom: var(--spacing-xl); }
.section-card h2 { font-size: var(--font-size-lg); font-weight: 600; margin-bottom: var(--spacing-lg); display: flex; align-items: center; gap: var(--spacing-sm); }
/* Data table */
.data-table { width: 100%; border-collapse: collapse; }
.data-table th { text-align: left; padding: var(--spacing-sm) var(--spacing-md); background: var(--background); font-weight: 600; font-size: var(--font-size-xs); color: var(--text-secondary); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px; }
.data-table td { padding: var(--spacing-sm) var(--spacing-md); border-bottom: 1px solid var(--border); font-size: var(--font-size-sm); }
.data-table tr:last-child td { border-bottom: none; }
.data-table tr:hover td { background: var(--background); }
/* User cell */
.user-cell { display: flex; align-items: center; gap: var(--spacing-sm); }
.user-cell a { color: var(--primary); text-decoration: none; font-weight: 500; }
.user-cell a:hover { text-decoration: underline; }
.user-avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--primary); color: white; display: flex; align-items: center; justify-content: center; font-size: var(--font-size-xs); font-weight: 600; flex-shrink: 0; }
/* Badges */
.badge { display: inline-block; padding: 2px 8px; border-radius: var(--radius); font-size: var(--font-size-xs); font-weight: 500; }
.badge-critical { background: #7f1d1d; color: white; }
.badge-high { background: #fee2e2; color: #991b1b; }
.badge-medium { background: #fef3c7; color: #92400e; }
.badge-low { background: #e0f2fe; color: #0369a1; }
.badge-ok { background: #dcfce7; color: #166534; }
.badge-active { background: #dcfce7; color: #166534; }
.badge-at-risk, .badge-at_risk { background: #fef3c7; color: #92400e; }
.badge-dormant { background: #fee2e2; color: #991b1b; }
/* Engagement bars */
.engagement-bar-wrap { height: 8px; background: var(--background); border-radius: 4px; overflow: hidden; }
.engagement-bar { height: 100%; border-radius: 4px; min-width: 2px; transition: width 0.3s ease; }
/* Horizontal bars */
.bar-container { height: 20px; background: var(--background); border-radius: var(--radius-sm); overflow: hidden; min-width: 100px; }
.bar-fill { height: 100%; border-radius: var(--radius-sm); transition: width 0.3s ease; }
.bar-fill.primary { background: var(--primary); }
.bar-fill.green { background: #22c55e; }
.bar-fill.blue { background: #3b82f6; }
/* Section heatmap grid */
.sections-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--spacing-md); margin-bottom: var(--spacing-xl); }
.section-tile { padding: var(--spacing-lg); border-radius: var(--radius); text-align: center; transition: transform 0.2s; }
.section-tile:hover { transform: translateY(-2px); }
.section-tile h3 { font-size: var(--font-size-sm); font-weight: 600; margin-bottom: var(--spacing-sm); }
.section-tile .metric { font-size: var(--font-size-xs); color: rgba(0,0,0,0.6); }
/* Hourly heatmap */
.heatmap-table { border-collapse: collapse; width: 100%; }
.heatmap-table th { padding: 4px 6px; font-size: var(--font-size-xs); color: var(--text-secondary); font-weight: 500; }
.heatmap-cell { width: 28px; height: 28px; border-radius: 4px; margin: 1px; }
.heatmap-table td { padding: 1px; text-align: center; }
.heatmap-label { text-align: right; padding-right: 8px !important; font-weight: 500; font-size: var(--font-size-xs); color: var(--text-secondary); min-width: 30px; }
/* Two columns layout */
.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-xl); }
@media (max-width: 1024px) { .two-columns { grid-template-columns: 1fr; } }
/* Alert action buttons */
.btn-alert-action { padding: 4px 10px; font-size: var(--font-size-xs); border: 1px solid var(--primary); color: var(--primary); background: white; border-radius: var(--radius-sm); cursor: pointer; white-space: nowrap; transition: var(--transition); }
.btn-alert-action:hover { background: var(--primary); color: white; }
.btn-alert-action:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-alert-action.sent { border-color: var(--success); color: var(--success); }
/* Issue tags */
.issue-tag { display: inline-block; padding: 2px 8px; font-size: var(--font-size-xs); border-radius: var(--radius-sm); background: #fef3c7; color: #92400e; }
/* Path transitions */
.transition-row { display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm) 0; border-bottom: 1px solid var(--border); font-size: var(--font-size-sm); }
.transition-arrow { color: var(--text-muted); }
.transition-count { font-weight: 600; min-width: 40px; text-align: right; }
/* Session length bars */
.bar-chart-row { display: flex; align-items: center; gap: var(--spacing-md); margin-bottom: var(--spacing-sm); }
.bar-chart-label { width: 100px; min-width: 100px; font-size: var(--font-size-sm); color: var(--text-secondary); }
.bar-chart-bar { flex: 1; height: 28px; background: var(--background); border-radius: var(--radius-sm); overflow: hidden; }
.bar-chart-fill { height: 100%; background: var(--primary); border-radius: var(--radius-sm); display: flex; align-items: center; padding-left: var(--spacing-sm); }
.bar-chart-fill span { color: white; font-size: var(--font-size-xs); font-weight: 600; }
/* Overview charts */
.chart-container { position: relative; height: 300px; }
/* Filter buttons */
.filter-group { display: flex; gap: var(--spacing-xs); }
.filter-btn { padding: 4px 12px; border: 1px solid var(--border); background: white; border-radius: var(--radius-sm); font-size: var(--font-size-xs); cursor: pointer; text-decoration: none; color: var(--text-secondary); }
.filter-btn.active { background: var(--primary); color: white; border-color: var(--primary); }
/* Export button */
.btn-export { display: inline-flex; align-items: center; gap: var(--spacing-xs); padding: var(--spacing-sm) var(--spacing-md); background: white; border: 1px solid var(--border); border-radius: var(--radius); font-size: var(--font-size-sm); color: var(--text-secondary); text-decoration: none; cursor: pointer; }
.btn-export:hover { background: var(--background); }
/* Table scroll */
.table-scroll { max-height: 500px; overflow-y: auto; }
/* Alerts */
.alert-card { display: flex; align-items: flex-start; gap: var(--spacing-md); padding: var(--spacing-md) var(--spacing-lg); border-radius: var(--radius); margin-bottom: var(--spacing-sm); }
.alert-card.critical { background: #fef2f2; border-left: 4px solid #dc2626; }
.alert-card.high { background: #fffbeb; border-left: 4px solid #f59e0b; }
.alert-card.medium { background: #eff6ff; border-left: 4px solid #3b82f6; }
.alert-icon { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 14px; }
.alert-card.critical .alert-icon { background: #fee2e2; }
.alert-card.high .alert-icon { background: #fef3c7; }
.alert-card.medium .alert-icon { background: #dbeafe; }
.alert-body { flex: 1; }
.alert-message { font-size: var(--font-size-sm); font-weight: 600; color: var(--text-primary); }
.alert-detail { font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 2px; }
.alert-action { flex-shrink: 0; }
.alert-action a { padding: 4px 12px; background: white; border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--font-size-xs); color: var(--primary); text-decoration: none; }
.alert-action a:hover { background: var(--background); }
/* Resolved section toggle */
details[open] summary span:first-child { transform: rotate(90deg); }
details summary::-webkit-details-marker { display: none; }
/* Reset password button */
.btn-reset-pw { padding: 4px 10px; background: white; border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--font-size-xs); cursor: pointer; white-space: nowrap; transition: var(--transition); }
.btn-reset-pw:hover { background: var(--background); border-color: var(--primary); }
.btn-reset-pw.sent { background: #dcfce7; color: #166534; border-color: #86efac; cursor: default; }
.btn-reset-pw.error { background: #fef2f2; color: #991b1b; border-color: #fca5a5; }
/* Responsive */
@media (max-width: 768px) {
.insights-header { flex-direction: column; align-items: flex-start; }
.tabs { overflow-x: auto; flex-wrap: nowrap; }
.sections-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
.data-table { font-size: var(--font-size-xs); }
.data-table th, .data-table td { padding: var(--spacing-xs) var(--spacing-sm); }
}
</style>
{% endblock %}
{% block content %}
<div class="admin-container" style="max-width: 1400px; margin: 0 auto; padding: var(--spacing-lg);">
<!-- Header -->
<div class="insights-header">
<h1>Analityka</h1>
<div class="insights-controls">
<div class="period-tabs">
<a href="{{ url_for('admin.user_insights', tab=tab, period='day') }}" class="period-tab {% if period == 'day' %}active{% endif %}">Dziś</a>
<a href="{{ url_for('admin.user_insights', tab=tab, period='week') }}" class="period-tab {% if period == 'week' %}active{% endif %}">7 dni</a>
<a href="{{ url_for('admin.user_insights', tab=tab, period='month') }}" class="period-tab {% if period == 'month' %}active{% endif %}">30 dni</a>
</div>
{% if tab in ['problems', 'engagement', 'pages'] %}
<a href="{{ url_for('admin.user_insights_export', type=tab, period=period) }}" class="btn-export">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
CSV
</a>
{% endif %}
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<a href="{{ url_for('admin.user_insights', tab='overview', period=period) }}" class="tab-link {% if tab == 'overview' %}active{% endif %}">Przegląd</a>
<a href="{{ url_for('admin.user_insights', tab='engagement', period=period) }}" class="tab-link {% if tab == 'engagement' %}active{% endif %}">Zaangażowanie</a>
<a href="{{ url_for('admin.user_insights', tab='pages', period=period) }}" class="tab-link {% if tab == 'pages' %}active{% endif %}">Strony</a>
<a href="{{ url_for('admin.user_insights', tab='problems', period=period) }}" class="tab-link {% if tab == 'problems' %}active{% endif %}">Problemy</a>
<a href="{{ url_for('admin.user_insights', tab='chat', period=period) }}" class="tab-link {% if tab == 'chat' %}active{% endif %}">Chat & Konwersje</a>
</div>
<!-- ============================================================ -->
<!-- TAB: PROBLEMS -->
<!-- ============================================================ -->
{% if tab == 'problems' %}
<!-- Summary cards -->
<div class="stats-grid">
<div class="stat-card {% if data.never_logged_in > 0 %}error{% else %}success{% endif %}">
<div class="stat-value">{{ data.never_logged_in }}</div>
<div class="stat-label">Nigdy nie zalogowani</div>
</div>
<div class="stat-card {% if data.locked_accounts > 0 %}error{% else %}success{% endif %}">
<div class="stat-value">{{ data.locked_accounts }}</div>
<div class="stat-label">Zablokowane konta</div>
</div>
<div class="stat-card {% if data.failed_logins > 5 %}warning{% else %}info{% endif %}">
<div class="stat-value">{{ data.failed_logins }}</div>
<div class="stat-label">Nieudane logowania</div>
</div>
<div class="stat-card {% if data.password_resets > 3 %}warning{% else %}info{% endif %}">
<div class="stat-value">{{ data.password_resets }}</div>
<div class="stat-label">Resety hasła</div>
</div>
</div>
<!-- Members who need help -->
{% if data.alerts %}
<div class="section-card">
<h2>Członkowie wymagający pomocy <span class="badge badge-high">{{ data.alerts|length }}</span></h2>
{% for alert in data.alerts %}
<div class="alert-card {{ alert.priority }}" style="margin-bottom: var(--spacing-sm);">
<div class="alert-icon" style="font-size: 24px;">
{% if alert.type == 'never_logged_in' %}👤{% elif alert.type == 'locked' %}🔒{% elif alert.type == 'reset_no_effect' %}📧{% elif alert.type == 'repeat_resets' %}🔄{% endif %}
</div>
<div class="alert-body" style="flex: 1;">
<div style="font-weight: 600; margin-bottom: 2px;">{{ alert.user.name or alert.user.email }}</div>
<div style="color: var(--text-secondary); font-size: var(--font-size-sm);">{{ alert.message }}</div>
<div style="color: var(--text-muted); font-size: var(--font-size-xs); margin-top: 2px;">{{ alert.detail }}</div>
</div>
<div style="display: flex; gap: 8px; align-items: center; flex-shrink: 0;">
{% if alert.type == 'never_logged_in' %}
<button class="btn-alert-action" onclick="sendWelcome({{ alert.user.id }}, this)">📧 Wyślij zaproszenie</button>
{% elif alert.type == 'reset_no_effect' or alert.type == 'repeat_resets' %}
<button class="btn-alert-action" onclick="sendReset({{ alert.user.id }}, this)">🔑 Reset hasła</button>
{% elif alert.type == 'locked' %}
<a href="{{ url_for('admin.user_insights_profile', user_id=alert.user.id, ref_tab=tab, ref_period=period) }}" class="btn-alert-action">Odblokuj</a>
{% endif %}
<a href="{{ url_for('admin.user_insights_profile', user_id=alert.user.id, ref_tab=tab, ref_period=period) }}" style="font-size: var(--font-size-sm); color: var(--primary);">Profil →</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="section-card">
<div style="text-align: center; padding: var(--spacing-xl); color: var(--text-muted);">
<p style="font-size: var(--font-size-lg);">Wszystko w porządku — brak członków wymagających pomocy.</p>
</div>
</div>
{% endif %}
<!-- Technical problems (collapsed, for developer) -->
{% if data.js_errors > 0 or data.slow_pages %}
<div class="section-card" style="opacity: 0.7;">
<details>
<summary style="cursor: pointer; font-size: var(--font-size-lg); font-weight: 600; padding: var(--spacing-xs) 0; list-style: none; display: flex; align-items: center; gap: var(--spacing-sm);">
<span style="transition: transform 0.2s; display: inline-block;">&#9654;</span>
Problemy techniczne
<span style="font-size: var(--font-size-xs); font-weight: 400; color: var(--text-muted);">— dla developera</span>
</summary>
<div style="margin-top: var(--spacing-md);">
{% if data.top_js_errors %}
<h3 style="font-size: var(--font-size-md); margin-bottom: var(--spacing-sm);">Błędy JavaScript ({{ data.js_errors }})</h3>
<div class="table-scroll" style="margin-bottom: var(--spacing-lg);">
<table class="data-table" style="font-size: var(--font-size-sm);">
<thead><tr><th>Błąd</th><th style="width: 80px;">Ile razy</th></tr></thead>
<tbody>
{% for e in data.top_js_errors %}
<tr>
<td style="font-family: monospace; font-size: var(--font-size-xs); word-break: break-all;">{{ e.message }}</td>
<td style="text-align: right; font-weight: 600;">{{ e.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if data.slow_pages %}
<h3 style="font-size: var(--font-size-md); margin-bottom: var(--spacing-sm);">Wolne strony (> 3s)</h3>
<div class="table-scroll">
<table class="data-table" style="font-size: var(--font-size-sm);">
<thead><tr><th>Strona</th><th style="width: 80px;">Ile razy</th><th style="width: 100px;">Śr. czas</th></tr></thead>
<tbody>
{% for p in data.slow_pages %}
<tr>
<td style="font-size: var(--font-size-xs);">{{ p.label }}</td>
<td style="text-align: right;">{{ p.count }}</td>
<td style="text-align: right; color: var(--error); font-weight: 600;">{{ (p.avg_ms / 1000)|round(1) }}s</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</details>
</div>
{% endif %}
<!-- ============================================================ -->
<!-- TAB: ENGAGEMENT -->
<!-- ============================================================ -->
{% elif tab == 'engagement' %}
<div class="stats-grid">
<div class="stat-card success">
<div class="stat-value">{{ data.active_7d }}</div>
<div class="stat-label">Aktywni (ostatnie 7 dni)</div>
</div>
<div class="stat-card info">
<div class="stat-value">{{ data.new_this_month }}</div>
<div class="stat-label">Nowi (ten miesiąc)</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ data.at_risk }}</div>
<div class="stat-label">Nie logowali się 8-30 dni</div>
</div>
<div class="stat-card error">
<div class="stat-value">{{ data.dormant }}</div>
<div class="stat-label">Nie logowali się 30+ dni</div>
</div>
</div>
<div class="section-card">
<h2>Aktywność członków</h2>
{% if data.engagement_list %}
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Członek</th>
<th style="min-width: 140px; cursor: help;" title="Wskaźnik aktywności z ostatnich 30 dni.&#10;Uwzględnia: sesje, odsłony stron, kliknięcia, czas na stronie, konwersje (RSVP, kontakt), wyszukiwania.&#10;Najedź na pasek, żeby zobaczyć szczegóły.">Aktywność ⓘ</th>
<th>Sesje</th>
<th>Odsłony</th>
<th>Ostatnie logowanie</th>
</tr>
</thead>
<tbody>
{% for e in data.engagement_list %}
<tr{% if e.score == 0 %} style="opacity: 0.5;"{% endif %}>
<td style="font-weight: 600; color: var(--text-muted); width: 40px;">{{ loop.index }}</td>
<td>
<div class="user-cell">
<div class="user-avatar">{{ e.user.name[0] if e.user.name else '?' }}</div>
<a href="{{ url_for('admin.user_insights_profile', user_id=e.user.id, ref_tab=tab, ref_period=period) }}">{{ e.user.name or e.user.email }}</a>
</div>
</td>
<td title="Ostatnie 30 dni:&#10;{{ e.detail.sessions_30d }} sesji (×3) + {{ e.detail.pages_30d }} stron (×1) + {{ e.detail.clicks_30d }} kliknięć (×0.5) + {{ e.detail.minutes_30d }} min (×2) + {{ e.detail.conversions_30d }} konwersji (×10) + {{ e.detail.searches_30d }} wyszukiwań (×2)&#10;= {{ e.detail.raw }} pkt → skala log. {{ e.score }}/100">
<div class="engagement-bar-wrap">
{% set bar_color = '#16a34a' if e.score >= 40 else '#f59e0b' if e.score >= 10 else '#d1d5db' %}
<div class="engagement-bar" style="width: {{ [e.score, 100]|min }}%; background: {{ bar_color }};"></div>
</div>
</td>
<td>{{ e.sessions }}</td>
<td>{{ e.page_views }}</td>
<td style="white-space: nowrap; color: {% if e.last_login == 'Nigdy' %}var(--danger){% elif e.days_since is not none and e.days_since > 30 %}var(--text-muted){% else %}var(--text-secondary){% endif %};">
{{ e.last_login }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: var(--spacing-xl); color: var(--text-muted);">
<p>Brak danych o aktywności członków.</p>
</div>
{% endif %}
</div>
<!-- ============================================================ -->
<!-- TAB: PAGE MAP -->
<!-- ============================================================ -->
{% elif tab == 'pages' %}
<!-- Section heatmap -->
<div class="section-card">
<h2>Popularność sekcji</h2>
<div class="sections-grid">
{% for s in data.sections %}
<div class="section-tile" style="background: rgba(34, 197, 94, {{ s.intensity / 100 * 0.3 + 0.05 }});">
<h3>{{ s.name }}</h3>
<div class="stat-value" style="font-size: var(--font-size-lg);">{{ s.views }}</div>
<div class="metric">{{ s.unique_users }} unikalnych &middot; {{ s.avg_time_fmt }} śr.</div>
</div>
{% endfor %}
</div>
</div>
<!-- Top pages -->
<div class="section-card">
<h2>Top 50 stron</h2>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Strona</th>
<th style="width: 200px;">Odsłony</th>
<th>Unikalni</th>
<th>Śr. czas</th>
<th>Śr. scroll</th>
<th>Śr. ładowanie</th>
</tr>
</thead>
<tbody>
{% for p in data.top_pages %}
<tr>
<td style="max-width: 300px; word-break: break-all; font-size: var(--font-size-xs);" title="{{ p.path }}">{{ p.label }}</td>
<td>
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
<div class="bar-container" style="flex: 1;">
<div class="bar-fill primary" style="width: {{ p.bar_pct }}%;"></div>
</div>
<span style="font-weight: 600; min-width: 40px; text-align: right;">{{ p.views }}</span>
</div>
</td>
<td>{{ p.unique_users }}</td>
<td>{{ p.avg_time_fmt }}</td>
<td>{{ p.avg_scroll }}%</td>
<td>
<span {% if p.avg_load > 3000 %}style="color: var(--error); font-weight: 600;"{% elif p.avg_load > 1500 %}style="color: #d97706;"{% endif %}>
{{ p.avg_load }}ms
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Ignored pages -->
{% if data.ignored_pages %}
<div class="section-card">
<h2>Nieużywane strony <span class="badge badge-low">< 5 odsłon/30d</span></h2>
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing-sm);">
{% for p in data.ignored_pages %}
<span style="padding: 4px 10px; background: var(--background); border-radius: var(--radius-sm); font-size: var(--font-size-xs); font-family: monospace; color: var(--text-muted);">
{{ p.path }} ({{ p.views }})
</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Wyszukiwania -->
<div class="two-columns">
<div class="section-card">
<h2>Top wyszukiwania</h2>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Fraza</th>
<th>Liczba</th>
<th>Śr. wyników</th>
</tr>
</thead>
<tbody>
{% for search in data.top_searches %}
<tr>
<td style="font-weight: 500;">{{ search.query_normalized }}</td>
<td>{{ search.count }}</td>
<td>{{ search.avg_results|round(1) if search.avg_results else 0 }}</td>
</tr>
{% else %}
<tr>
<td colspan="3" style="text-align: center; color: var(--text-muted); padding: var(--spacing-lg);">Brak danych wyszukiwań</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="section-card">
<h2>Wyszukiwania bez wyników</h2>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Fraza</th>
<th>Liczba</th>
</tr>
</thead>
<tbody>
{% for search in data.searches_no_results %}
<tr>
<td style="color: var(--error); font-weight: 500;">{{ search.query_normalized }}</td>
<td>{{ search.count }}</td>
</tr>
{% else %}
<tr>
<td colspan="2" style="text-align: center; color: var(--text-muted); padding: var(--spacing-lg);">Wszystkie wyszukiwania miały wyniki</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Search Effectiveness -->
<div class="section-card">
<h2>Skuteczność wyszukiwarki</h2>
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); margin-bottom: var(--spacing-lg);">
<div class="stat-card info">
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.search_effectiveness.total }}</div>
<div class="stat-label">Wyszukiwań</div>
</div>
<div class="stat-card {% if data.search_effectiveness.ctr >= 40 %}success{% elif data.search_effectiveness.ctr >= 20 %}warning{% else %}error{% endif %}">
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.search_effectiveness.ctr }}%</div>
<div class="stat-label">Klikalność (CTR)</div>
</div>
<div class="stat-card info">
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.search_effectiveness.avg_position or '—' }}</div>
<div class="stat-label">Śr. pozycja kliknięcia</div>
</div>
<div class="stat-card {% if data.search_effectiveness.ctr < 20 %}error{% else %}success{% endif %}">
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.search_effectiveness.with_click }}</div>
<div class="stat-label">Z kliknięciem</div>
</div>
</div>
{% if data.search_effectiveness.top_clicked %}
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin-bottom: var(--spacing-sm); color: var(--text-secondary);">Najczęściej wybierane firmy z wyszukiwarki</h3>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
{% for c in data.search_effectiveness.top_clicked %}
<span style="padding: 4px 12px; background: var(--background); border-radius: var(--radius); font-size: var(--font-size-sm);">
<strong>{{ c.name }}</strong> <span style="color: var(--text-muted);">({{ c.clicks }}x)</span>
</span>
{% endfor %}
</div>
{% endif %}
</div>
<!-- Content Engagement (Open Rates) -->
<div class="section-card">
<h2>Zasięg treści <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(30 dni)</small></h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--spacing-lg);">
{% set content_types = [
('Aktualności', data.content_engagement.announcements, '#3b82f6'),
('Forum', data.content_engagement.forum, '#8b5cf6'),
('Tablica B2B', data.content_engagement.classifieds, '#10b981')
] %}
{% for label, stats, color in content_types %}
<div style="background: var(--background); padding: var(--spacing-lg); border-radius: var(--radius-lg); border-left: 4px solid {{ color }};">
<h3 style="font-size: var(--font-size-base); font-weight: 600; margin-bottom: var(--spacing-md);">{{ label }}</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-sm); margin-bottom: var(--spacing-md);">
<div>
<div style="font-size: var(--font-size-2xl); font-weight: 700;">{{ stats.published }}</div>
<div style="font-size: var(--font-size-xs); color: var(--text-muted);">nowych (30 dni)</div>
</div>
<div>
<div style="font-size: var(--font-size-2xl); font-weight: 700;">{{ stats.read_by }}</div>
<div style="font-size: var(--font-size-xs); color: var(--text-muted);">aktywnych czytelników</div>
</div>
</div>
<div style="margin-bottom: 4px; display: flex; justify-content: space-between;">
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);" title="% aktywnych członków, którzy czytali">Zasięg</span>
<span style="font-size: var(--font-size-sm); font-weight: 600; color: {{ '#16a34a' if stats.open_rate >= 50 else ('#f59e0b' if stats.open_rate >= 25 else '#ef4444') }};">{{ stats.open_rate }}%</span>
</div>
<div style="height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden;">
<div style="height: 100%; width: {{ stats.open_rate }}%; background: {{ color }}; border-radius: 4px;"></div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- ============================================================ -->
<!-- ============================================================ -->
<!-- TAB: OVERVIEW -->
<!-- ============================================================ -->
{% elif tab == 'overview' %}
<!-- KPI Stat Cards with Trends -->
<div class="stats-grid">
<!-- Active members with goal bar -->
<div class="stat-card success">
<div class="stat-value">{{ data.kpi.active_users }}<span style="font-size: var(--font-size-sm); color: var(--text-muted); font-weight: 400;">/{{ data.kpi.total_members }}</span>
{% if data.kpi.active_users_trend is not none %}
<span style="font-size: var(--font-size-sm); margin-left: 6px; color: {{ 'var(--success)' if data.kpi.active_users_trend >= 0 else 'var(--error)' }}">
{{ '▲' if data.kpi.active_users_trend > 0 else ('▼' if data.kpi.active_users_trend < 0 else '') }} {{ data.kpi.active_users_trend|abs }}%
</span>
{% endif %}
</div>
<div class="stat-label">Aktywni członkowie ({{ data.kpi.active_pct }}%)</div>
<div style="margin-top: 4px; height: 4px; background: #e2e8f0; border-radius: 2px;">
<div style="height: 100%; width: {{ data.kpi.active_pct }}%; background: var(--success); border-radius: 2px;"></div>
</div>
</div>
{% for kpi_item in [
('sessions', 'Sesje', 'info', false),
('pageviews', 'Odsłony', 'info', false),
('bounce_rate', 'Współczynnik odrzuceń', 'info', true),
] %}
{% set key = kpi_item[0] %}
{% set label = kpi_item[1] %}
{% set is_bounce = kpi_item[3] %}
{% set val = data.kpi[key] %}
{% set trend = data.kpi[key ~ '_trend'] %}
{% if is_bounce %}
<div class="stat-card {% if val > 85 %}warning{% else %}success{% endif %}" title="Na portalu członkowskim 70-85% jest normalne — użytkownicy sprawdzają jedną informację i wychodzą">
{% else %}
<div class="stat-card {{ kpi_item[2] }}">
{% endif %}
<div class="stat-value">{{ val }}{% if is_bounce %}%{% endif %}
{% if trend is not none %}
<span style="font-size: var(--font-size-sm); margin-left: 6px; {% if is_bounce %}color: {{ 'var(--success)' if trend <= 0 else 'var(--error)' }}{% else %}color: {{ 'var(--success)' if trend >= 0 else 'var(--error)' }}{% endif %}">
{{ '▲' if trend > 0 else ('▼' if trend < 0 else '') }} {{ trend|abs }}%
</span>
{% endif %}
</div>
<div class="stat-label">{{ label }}</div>
</div>
{% endfor %}
</div>
<!-- Filter -->
<div style="margin-bottom: var(--spacing-lg);">
<div class="filter-group">
<a href="{{ url_for('admin.user_insights', tab='overview', period=period, filter='all') }}" class="filter-btn {% if data.filter_type == 'all' %}active{% endif %}">Wszyscy</a>
<a href="{{ url_for('admin.user_insights', tab='overview', period=period, filter='logged') }}" class="filter-btn {% if data.filter_type == 'logged' %}active{% endif %}">Zalogowani</a>
<a href="{{ url_for('admin.user_insights', tab='overview', period=period, filter='anonymous') }}" class="filter-btn {% if data.filter_type == 'anonymous' %}active{% endif %}">Anonimowi</a>
</div>
</div>
<!-- Sessions + Page Views chart -->
<div class="section-card">
<h2>Sesje i odsłony <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(zawsze ostatnie 30 dni)</small></h2>
<div class="chart-container">
<canvas id="sessionsChart"></canvas>
</div>
</div>
<div class="two-columns">
<!-- Logged vs Anonymous doughnut -->
<div class="section-card">
<h2>Zalogowani vs Anonimowi</h2>
<div class="chart-container" style="height: 250px;">
<canvas id="authChart"></canvas>
</div>
</div>
<!-- Devices stacked bar -->
<div class="section-card">
<h2>Urządzenia (tygodniowo)</h2>
<div class="chart-container" style="height: 250px;">
<canvas id="devicesChart"></canvas>
</div>
</div>
</div>
<!-- Hourly heatmap -->
<div class="section-card">
<h2>Aktywność godzinowa (30 dni)</h2>
<div style="overflow-x: auto;">
<table class="heatmap-table">
<thead>
<tr>
<th></th>
{% for h in range(24) %}
<th>{{ h }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in data.heatmap %}
<tr>
<td class="heatmap-label">{{ row.name }}</td>
{% for cell in row.hours %}
<td title="{{ row.name }} {{ loop.index0 }}:00 — {{ cell.count }} sesji">
<div class="heatmap-cell" style="background: rgba(34, 197, 94, {{ cell.intensity / 100 * 0.8 + 0.05 }}); display: inline-block;"></div>
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="two-columns">
<!-- Feature Adoption -->
<div class="section-card">
<h2>Adopcja funkcji <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(30 dni, zalogowani)</small></h2>
{% for f in data.adoption %}
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
<div style="width: 120px; font-size: var(--font-size-sm); font-weight: 500;">{{ f.name }}</div>
<div style="flex: 1; height: 20px; background: #e2e8f0; border-radius: 4px; overflow: hidden;">
<div style="height: 100%; width: {{ f.pct }}%; background: {{ '#10b981' if f.pct >= 30 else ('#f59e0b' if f.pct >= 10 else '#ef4444') }}; border-radius: 4px; display: flex; align-items: center; padding-left: 6px;">
{% if f.pct >= 8 %}<span style="font-size: 11px; color: white; font-weight: 600;">{{ f.pct }}%</span>{% endif %}
</div>
</div>
<div style="width: 50px; font-size: var(--font-size-xs); color: var(--text-muted); text-align: right;">{{ f.users }} os.</div>
</div>
{% endfor %}
</div>
<!-- Event Comparison -->
<div class="section-card">
<h2>Porównanie wydarzeń <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(ostatnie 60 dni)</small></h2>
{% if data.events_comparison %}
<div class="table-scroll" style="max-height: 350px;">
<table class="data-table" style="font-size: var(--font-size-sm);">
<thead>
<tr>
<th>Wydarzenie</th>
<th style="text-align: center;">Data</th>
<th style="text-align: center;" title="Wyświetlenia strony wydarzenia">Odsłony</th>
<th style="text-align: center;" title="Unikalni odwiedzający">Osoby</th>
<th style="text-align: center;" title="Potwierdzone zapisy">RSVP</th>
</tr>
</thead>
<tbody>
{% for e in data.events_comparison %}
<tr>
<td style="max-width: 200px;">{{ e.title }}</td>
<td style="text-align: center; white-space: nowrap;">{{ e.date }}</td>
<td style="text-align: center;">{{ e.views }}</td>
<td style="text-align: center;">{{ e.unique_viewers }}</td>
<td style="text-align: center; font-weight: 600;">{{ e.rsvp }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p style="color: var(--text-muted); text-align: center; padding: var(--spacing-lg);">Brak wydarzeń w ostatnich 60 dniach</p>
{% endif %}
</div>
</div>
<!-- Session length distribution -->
{% if data.session_lengths %}
<div class="section-card">
<h2>Ile stron ogląda się w jednej wizycie</h2>
<div style="display: flex; gap: var(--spacing-md); flex-wrap: wrap;">
{% for s in data.session_lengths %}
<div style="flex: 1; min-width: 100px; text-align: center; padding: var(--spacing-md); background: var(--background); border-radius: var(--radius);">
<div style="font-size: var(--font-size-2xl); font-weight: 700; color: var(--primary);">{{ s.count }}</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary);">{{ s.bucket }}</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Referrer Sources + Company Popularity -->
<div class="two-columns">
<!-- Referrer Sources -->
<div class="section-card">
<h2>Skąd przychodzą użytkownicy <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(30 dni)</small></h2>
{% if data.referrer_sources %}
{% for r in data.referrer_sources %}
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px;">
<div style="width: 110px; font-size: var(--font-size-sm); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{ r.domain }}">{{ r.domain }}</div>
<div style="flex: 1; height: 18px; background: #e2e8f0; border-radius: 4px; overflow: hidden;">
<div style="height: 100%; width: {{ r.bar_pct }}%; background: #6366f1; border-radius: 4px;"></div>
</div>
<div style="width: 45px; font-size: var(--font-size-xs); color: var(--text-muted); text-align: right;">{{ r.count }}</div>
</div>
{% endfor %}
{% else %}
<p style="color: var(--text-muted); text-align: center; padding: var(--spacing-lg);">Brak danych o źródłach ruchu</p>
{% endif %}
</div>
<!-- Company Profile Popularity -->
<div class="section-card">
<h2>Popularność profili firm <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(30 dni)</small></h2>
{% if data.company_popularity %}
<div class="table-scroll" style="max-height: 350px;">
<table class="data-table" style="font-size: var(--font-size-sm);">
<thead>
<tr>
<th>Firma</th>
<th style="text-align: center;">Odsłony</th>
<th style="text-align: center;">Osoby</th>
</tr>
</thead>
<tbody>
{% for c in data.company_popularity %}
<tr>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis;">
<a href="{{ c.path }}" target="_blank" style="color: var(--primary); text-decoration: none;">{{ c.name }}</a>
</td>
<td style="text-align: center;">
<div style="display: flex; align-items: center; gap: 6px; justify-content: center;">
<div style="width: 60px; height: 12px; background: #e2e8f0; border-radius: 3px; overflow: hidden;">
<div style="height: 100%; width: {{ (c.views / data.max_company_views * 100)|int }}%; background: #10b981; border-radius: 3px;"></div>
</div>
{{ c.views }}
</div>
</td>
<td style="text-align: center;">{{ c.unique_viewers }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p style="color: var(--text-muted); text-align: center; padding: var(--spacing-lg);">Brak danych o profilach firm</p>
{% endif %}
</div>
</div>
<!-- Companies needing attention -->
{% if data.companies_attention %}
<div class="section-card">
<h2>Firmy wymagające uwagi
<span class="badge badge-high">{{ data.companies_attention_total }}</span>
</h2>
<div class="table-scroll" style="max-height: 400px;">
<table class="data-table" style="font-size: var(--font-size-sm);">
<thead>
<tr>
<th>Firma</th>
<th>Braki</th>
<th style="text-align: center;">Jakość</th>
<th></th>
</tr>
</thead>
<tbody>
{% for co in data.companies_attention %}
<tr>
<td style="font-weight: 500; max-width: 200px;">{{ co.name }}</td>
<td>
{% for issue in co.issues %}
<span class="issue-tag">{{ issue }}</span>
{% endfor %}
</td>
<td style="text-align: center;">
<span class="badge {% if co.quality == 'complete' %}badge-ok{% elif co.quality == 'enhanced' %}badge-medium{% else %}badge-high{% endif %}">{{ co.quality }}</span>
</td>
<td><a href="/company/{{ co.id }}/edit" style="font-size: var(--font-size-xs); color: var(--primary);">Edytuj →</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- ============================================================ -->
<!-- TAB: CHAT & CONVERSIONS -->
<!-- ============================================================ -->
{% elif tab == 'chat' %}
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card info">
<div class="stat-value">{{ data.period_conversations }}</div>
<div class="stat-label">Rozmów ({% if period == 'day' %}dziś{% elif period == 'week' %}7 dni{% else %}30 dni{% endif %})</div>
</div>
<div class="stat-card info">
<div class="stat-value">{{ data.period_messages }}</div>
<div class="stat-label">Wiadomości</div>
</div>
<div class="stat-card {% if data.satisfaction_rate >= 70 %}success{% elif data.satisfaction_rate >= 40 %}warning{% else %}error{% endif %}">
<div class="stat-value">{{ data.satisfaction_rate }}%</div>
<div class="stat-label">Satysfakcja ({{ data.positive_feedback }}+ / {{ data.negative_feedback }}-)</div>
</div>
<div class="stat-card info">
<div class="stat-value">{{ data.today_messages }}</div>
<div class="stat-label">Zapytań dzisiaj</div>
</div>
</div>
<div class="two-columns">
<!-- What do people ask about -->
<div class="section-card">
<h2>O czym pytają członkowie</h2>
{% if data.topics %}
{% for t in data.topics %}
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
<div style="width: 140px; font-size: var(--font-size-sm); font-weight: 500;">{{ t.name }}</div>
<div style="flex: 1; height: 20px; background: #e2e8f0; border-radius: 4px; overflow: hidden;">
<div style="height: 100%; width: {{ (t.count / data.max_topic * 100)|int }}%; background: #6366f1; border-radius: 4px; display: flex; align-items: center; padding-left: 6px;">
{% if (t.count / data.max_topic * 100)|int >= 15 %}<span style="font-size: 11px; color: white; font-weight: 600;">{{ t.count }}</span>{% endif %}
</div>
</div>
<div style="width: 40px; font-size: var(--font-size-xs); color: var(--text-muted); text-align: right;">{{ t.count }}</div>
</div>
{% endfor %}
{% else %}
<p style="color: var(--text-muted); text-align: center; padding: var(--spacing-lg);">Brak zapytań w wybranym okresie</p>
{% endif %}
</div>
<!-- Recent feedback -->
<div class="section-card">
<h2>Jak oceniają NordaGPT</h2>
{% if data.recent_feedback %}
<div style="max-height: 400px; overflow-y: auto;">
{% for msg in data.recent_feedback %}
<div style="padding: var(--spacing-sm) 0; border-bottom: 1px solid var(--border);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<span class="badge {% if msg.feedback_rating == 2 %}badge-active{% else %}badge-high{% endif %}">
{% if msg.feedback_rating == 2 %}Pomocne{% else %}Do poprawy{% endif %}
</span>
<span style="font-size: var(--font-size-xs); color: var(--text-muted);">
{{ msg.feedback_at.strftime('%d.%m %H:%M') if msg.feedback_at else '' }}
</span>
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); max-height: 50px; overflow: hidden;">
{{ msg.content[:150] }}{% if msg.content|length > 150 %}...{% endif %}
</div>
{% if msg.feedback_comment %}
<div style="margin-top: 4px; font-size: var(--font-size-xs); color: var(--text-primary);"><strong>Komentarz:</strong> {{ msg.feedback_comment }}</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p style="color: var(--text-muted); text-align: center; padding: var(--spacing-lg);">Brak ocen — członkowie mogą oceniać odpowiedzi kciukiem w górę/dół</p>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
{% if tab == 'overview' %}
// Sessions + Page Views line chart
const chartData = {{ data.chart_data|tojson|safe }};
const sessionsCtx = document.getElementById('sessionsChart').getContext('2d');
new Chart(sessionsCtx, {
type: 'line',
data: {
labels: chartData.labels,
datasets: [{
label: 'Sesje',
data: chartData.sessions,
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
fill: true,
tension: 0.4,
yAxisID: 'y'
}, {
label: 'Odsłony',
data: chartData.pageviews,
borderColor: '#22c55e',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
fill: true,
tension: 0.4,
yAxisID: 'y1'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'top' } },
scales: {
x: { grid: { display: false } },
y: { type: 'linear', display: true, position: 'left', beginAtZero: true, title: { display: true, text: 'Sesje' } },
y1: { type: 'linear', display: true, position: 'right', beginAtZero: true, grid: { drawOnChartArea: false }, title: { display: true, text: 'Odsłony' } }
}
}
});
// Auth doughnut
const authData = {{ data.logged_vs_anon|tojson|safe }};
const authCtx = document.getElementById('authChart').getContext('2d');
const authTotal = authData.logged + authData.anonymous;
const authPctLogged = authTotal > 0 ? Math.round(authData.logged / authTotal * 100) : 0;
const authPctAnon = authTotal > 0 ? Math.round(authData.anonymous / authTotal * 100) : 0;
new Chart(authCtx, {
type: 'doughnut',
data: {
labels: ['Zalogowani (' + authPctLogged + '%)', 'Anonimowi (' + authPctAnon + '%)'],
datasets: [{
data: [authData.logged, authData.anonymous],
backgroundColor: ['#6366f1', '#d1d5db'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: function(ctx) {
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
const pct = total > 0 ? Math.round(ctx.raw / total * 100) : 0;
return ctx.label + ': ' + ctx.raw + ' (' + pct + '%)';
}
}
}
}
}
});
// Devices stacked bar
const devData = {{ data.devices|tojson|safe }};
const devCtx = document.getElementById('devicesChart').getContext('2d');
new Chart(devCtx, {
type: 'bar',
data: {
labels: devData.labels,
datasets: [{
label: 'Desktop',
data: devData.desktop,
backgroundColor: '#6366f1'
}, {
label: 'Mobile',
data: devData.mobile,
backgroundColor: '#22c55e'
}, {
label: 'Tablet',
data: devData.tablet,
backgroundColor: '#f59e0b'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'top' } },
scales: {
x: { stacked: true, grid: { display: false } },
y: { stacked: true, beginAtZero: true }
}
}
});
{% endif %}
// Password reset buttons
document.querySelectorAll('.btn-reset-pw').forEach(function(btn) {
btn.addEventListener('click', async function() {
const uid = this.dataset.uid;
const name = this.dataset.name;
const email = this.dataset.email;
if (!confirm('Wysłać reset hasła do ' + name + ' (' + email + ')?')) return;
this.disabled = true;
this.textContent = '⏳...';
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const resp = await fetch('/admin/analytics/send-reset/' + uid, {
method: 'POST',
headers: {'X-CSRFToken': csrfToken}
});
const data = await resp.json();
if (data.success) {
this.textContent = '✓ Wysłano';
this.classList.add('sent');
} else {
this.textContent = '✗ ' + (data.error || 'Błąd');
this.classList.add('error');
this.disabled = false;
}
} catch (e) {
this.textContent = '✗ Błąd';
this.classList.add('error');
this.disabled = false;
}
});
});
// Alert action buttons
async function sendWelcome(userId, btn) {
btn.disabled = true;
btn.textContent = 'Wysyłanie...';
try {
const resp = await fetch('/admin/analytics/send-welcome/' + userId, {
method: 'POST',
headers: {'X-CSRFToken': document.querySelector('meta[name=csrf-token]')?.content || ''}
});
const data = await resp.json();
if (data.success) {
btn.textContent = '✓ Wysłano';
btn.classList.add('sent');
} else {
btn.textContent = '✗ ' + (data.error || 'Błąd');
btn.disabled = false;
}
} catch(e) {
btn.textContent = '✗ Błąd';
btn.disabled = false;
}
}
async function sendReset(userId, btn) {
btn.disabled = true;
btn.textContent = 'Wysyłanie...';
try {
const resp = await fetch('/admin/analytics/send-reset/' + userId, {
method: 'POST',
headers: {'X-CSRFToken': document.querySelector('meta[name=csrf-token]')?.content || ''}
});
const data = await resp.json();
if (data.success) {
btn.textContent = '✓ Wysłano';
btn.classList.add('sent');
} else {
btn.textContent = '✗ ' + (data.error || 'Błąd');
btn.disabled = false;
}
} catch(e) {
btn.textContent = '✗ Błąd';
btn.disabled = false;
}
}
{% endblock %}