nordabiz/templates/admin/user_insights.html
Maciej Pienczyn e3d0cc6deb
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
refactor: consolidate 3 analytics dashboards into 1 with 6 tabs
Merged analytics_dashboard, user_insights, and chat_analytics into a
single consolidated view at /admin/analytics with 6 tabs: Overview,
Engagement, Pages, Paths, Problems, Chat & Conversions.

- Menu reduced from 5 to 3 items (Analityka, Monitoring AI, Debug)
- All queries now use bot filtering consistently
- Old URLs redirect to new consolidated view
- Removed 1,380 lines of duplicate templates
- Net reduction: -1,328 lines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:28:31 +01:00

1120 lines
54 KiB
HTML

{% extends "base.html" %}
{% block title %}Analityka - Admin{% 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; }
/* Sparkline */
.sparkline { display: inline-flex; align-items: flex-end; gap: 2px; height: 24px; }
.sparkline-bar { width: 6px; background: var(--primary); border-radius: 1px; min-height: 2px; opacity: 0.7; }
/* WoW arrow */
.wow-up { color: #16a34a; font-weight: 600; }
.wow-down { color: #dc2626; font-weight: 600; }
.wow-flat { color: var(--text-muted); }
/* 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; } }
/* 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='paths', period=period) }}" class="tab-link {% if tab == 'paths' %}active{% endif %}">Ścieżki</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' %}
<div class="stats-grid">
<div class="stat-card error">
<div class="stat-value">{{ data.locked_accounts }}</div>
<div class="stat-label">Zablokowane konta</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ data.failed_logins }}</div>
<div class="stat-label">Nieudane logowania</div>
</div>
<div class="stat-card info">
<div class="stat-value">{{ data.password_resets }}</div>
<div class="stat-label">Resety hasła</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ data.js_errors }}</div>
<div class="stat-label">Błędy JS</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ data.security_alerts }}</div>
<div class="stat-label">Alerty bezpieczeństwa</div>
</div>
<div class="stat-card error">
<div class="stat-value">{{ data.never_logged_in }}</div>
<div class="stat-label">Nigdy nie zalogowani</div>
</div>
</div>
<!-- Proactive alerts -->
{% if data.alerts %}
<div class="section-card">
<h2>Alerty proaktywne
<span class="badge badge-high">{{ data.alerts|length }}</span>
</h2>
{% for alert in data.alerts %}
<div class="alert-card {{ alert.priority }}">
<div class="alert-icon">
{% if alert.type == 'never_logged_in' %}👤{% elif alert.type == 'locked' %}🔒{% elif alert.type == 'reset_no_effect' %}📧{% elif alert.type == 'repeat_resets' %}🔄{% else %}⚠{% endif %}
</div>
<div class="alert-body">
<div class="alert-message">{{ alert.user.name or alert.user.email }}: {{ alert.message }}</div>
<div class="alert-detail">{{ alert.detail }}</div>
</div>
<div class="alert-action">
<a href="{{ url_for('admin.user_insights_profile', user_id=alert.user.id, ref_tab=tab, ref_period=period) }}">Szczegóły →</a>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Remediation effectiveness -->
{% if data.remediation and data.remediation.total > 0 %}
<div class="section-card">
<h2>Skuteczność działań <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(30 dni)</small></h2>
<div class="stats-grid" style="margin-bottom: var(--spacing-lg);">
<div class="stat-card success">
<div class="stat-value">{{ data.remediation.success_rate }}%</div>
<div class="stat-label">Skuteczność</div>
</div>
<div class="stat-card success">
<div class="stat-value">{{ data.remediation.resolved }}</div>
<div class="stat-label">Zalogowali się</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ data.remediation.pending }}</div>
<div class="stat-label">Oczekują (< 48h)</div>
</div>
<div class="stat-card error">
<div class="stat-value">{{ data.remediation.failed }}</div>
<div class="stat-label">Brak reakcji</div>
</div>
{% if data.remediation.avg_resolution_hours is not none %}
<div class="stat-card info">
<div class="stat-value">
{% if data.remediation.avg_resolution_hours < 1 %}
{{ (data.remediation.avg_resolution_hours * 60)|int }} min
{% elif data.remediation.avg_resolution_hours < 24 %}
{{ data.remediation.avg_resolution_hours }} godz.
{% else %}
{{ (data.remediation.avg_resolution_hours / 24)|round(1) }} dni
{% endif %}
</div>
<div class="stat-label">Śr. czas do loginu</div>
</div>
{% endif %}
</div>
<div class="table-scroll" style="max-height: 350px;">
<table class="data-table">
<thead>
<tr>
<th>Użytkownik</th>
<th>Akcja</th>
<th>Wysłano</th>
<th>Wynik</th>
</tr>
</thead>
<tbody>
{% for r in data.remediation.entries %}
<tr>
<td>
<div class="user-cell">
<div class="user-avatar">{{ r.user.name[0] if r.user.name else '?' }}</div>
<a href="{{ url_for('admin.user_insights_profile', user_id=r.user.id, ref_tab=tab, ref_period=period) }}">{{ r.user.name or r.user.email }}</a>
</div>
</td>
<td>{{ r.action }}</td>
<td>{{ r.sent_at.strftime('%d.%m %H:%M') }}</td>
<td>
{% if r.result == 'resolved' %}
<span class="badge badge-active">{{ r.result_label }}</span>
{% elif r.result == 'pending' %}
<span class="badge badge-medium">{{ r.result_label }}</span>
{% else %}
<span class="badge badge-high">{{ r.result_label }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- Active problems -->
<div class="section-card">
<h2>Aktywne problemy
<span class="badge {% if data.active_problems|length > 10 %}badge-critical{% elif data.active_problems|length > 0 %}badge-high{% else %}badge-ok{% endif %}">
{{ data.active_problems|length }}
</span>
</h2>
{% if data.active_problems %}
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Użytkownik</th>
<th>Problem Score</th>
<th>Nieudane logowania</th>
<th>Resety hasła</th>
<th>Alerty</th>
<th>Błędy JS</th>
<th>Wolne strony</th>
<th>🔒</th>
<th>Ostatni login</th>
<th>Akcja</th>
</tr>
</thead>
<tbody>
{% for p in data.active_problems %}
<tr>
<td>
<div class="user-cell">
<div class="user-avatar">{{ p.user.name[0] if p.user.name else '?' }}</div>
<a href="{{ url_for('admin.user_insights_profile', user_id=p.user.id, ref_tab=tab, ref_period=period) }}">{{ p.user.name or p.user.email }}</a>
</div>
</td>
<td>
<span class="badge {% if p.score >= 51 %}badge-critical{% elif p.score >= 21 %}badge-high{% elif p.score >= 1 %}badge-medium{% else %}badge-ok{% endif %}">
{{ p.score }}
</span>
</td>
<td>{{ p.failed_logins }}</td>
<td>{{ p.password_resets }}</td>
<td>{{ p.security_alerts }}</td>
<td>{{ p.js_errors }}</td>
<td>{{ p.slow_pages }}</td>
<td>{% if p.is_locked %}<span class="badge badge-critical">Tak</span>{% endif %}</td>
<td>{{ p.last_login.strftime('%d.%m %H:%M') if p.last_login else 'Nigdy' }}</td>
<td>
<button class="btn-reset-pw" data-uid="{{ p.user.id }}" data-name="{{ p.user.name or p.user.email }}" data-email="{{ p.user.email }}">📧 Reset</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: var(--spacing-xl); color: var(--text-muted);">
<p>Brak aktywnych problemów w wybranym okresie.</p>
</div>
{% endif %}
</div>
<!-- Resolved problems (collapsible) -->
{% if data.resolved_problems %}
<div class="section-card" style="opacity: 0.8;">
<details>
<summary style="cursor: pointer; display: flex; align-items: center; gap: var(--spacing-sm); font-size: var(--font-size-lg); font-weight: 600; padding: var(--spacing-xs) 0; list-style: none;">
<span style="transition: transform 0.2s; display: inline-block;">&#9654;</span>
Rozwiązane problemy
<span class="badge badge-ok">{{ data.resolved_problems|length }}</span>
<span style="font-size: var(--font-size-xs); font-weight: 400; color: var(--text-muted);">— zalogowali się po problemach</span>
</summary>
<div class="table-scroll" style="margin-top: var(--spacing-md);">
<table class="data-table">
<thead>
<tr>
<th>Użytkownik</th>
<th>Było problemów</th>
<th>Nieudane logowania</th>
<th>Resety hasła</th>
<th>Alerty</th>
<th>Rozwiązano</th>
</tr>
</thead>
<tbody>
{% for p in data.resolved_problems %}
<tr>
<td>
<div class="user-cell">
<div class="user-avatar" style="background: var(--success);">{{ p.user.name[0] if p.user.name else '?' }}</div>
<a href="{{ url_for('admin.user_insights_profile', user_id=p.user.id, ref_tab=tab, ref_period=period) }}">{{ p.user.name or p.user.email }}</a>
</div>
</td>
<td><span class="badge badge-ok">{{ p.score }}</span></td>
<td>{{ p.failed_logins }}</td>
<td>{{ p.password_resets }}</td>
<td>{{ p.security_alerts }}</td>
<td><span class="badge badge-active">Zalogowano {{ p.resolved_at.strftime('%d.%m %H:%M') }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</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 ({{ period }})</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ data.at_risk }}</div>
<div class="stat-label">Zagrożeni (8-30d)</div>
</div>
<div class="stat-card error">
<div class="stat-value">{{ data.dormant }}</div>
<div class="stat-label">Uśpieni (30d+)</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>
<div class="section-card">
<h2>Ranking zaangażowania</h2>
{% if data.engagement_list %}
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Użytkownik</th>
<th>Score</th>
<th>Sesje</th>
<th>Odsłony</th>
<th>Zmiana</th>
<th>Aktywność</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for e in data.engagement_list %}
<tr>
<td style="font-weight: 600; color: var(--text-muted);">{{ 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><strong>{{ e.score }}</strong></td>
<td>{{ e.sessions }}</td>
<td>{{ e.page_views }}</td>
<td>
{% if e.wow is not none %}
{% if e.wow > 0 %}
<span class="wow-up">▲ {{ e.wow }}%</span>
{% elif e.wow < 0 %}
<span class="wow-down">▼ {{ e.wow|abs }}%</span>
{% else %}
<span class="wow-flat">— 0%</span>
{% endif %}
{% else %}
<span class="wow-flat">N/A</span>
{% endif %}
</td>
<td>
<div class="sparkline">
{% set max_spark = e.sparkline|max if e.sparkline|max > 0 else 1 %}
{% for val in e.sparkline %}
<div class="sparkline-bar" style="height: {{ (val / max_spark * 22 + 2)|int }}px;"></div>
{% endfor %}
</div>
</td>
<td>
<span class="badge badge-{{ e.status }}">
{% if e.status == 'active' %}Aktywny{% elif e.status == 'at_risk' %}Zagrożony{% else %}Uśpiony{% endif %}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: var(--spacing-xl); color: var(--text-muted);">
<p>Brak danych o zaangażowaniu w wybranym okresie.</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 }}s ś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>Ścieżka</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-family: monospace; font-size: var(--font-size-xs);">{{ p.path }}</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 }}s</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>
<!-- ============================================================ -->
<!-- TAB: PATHS -->
<!-- ============================================================ -->
{% elif tab == 'paths' %}
<div class="two-columns">
<!-- Entry pages -->
<div class="section-card">
<h2>Strony wejściowe (top 10)</h2>
{% for p in data.entry_pages %}
<div class="bar-chart-row">
<div class="bar-chart-label" style="width: 200px; min-width: 200px; font-family: monospace; font-size: var(--font-size-xs); word-break: break-all;">{{ p.path }}</div>
<div class="bar-chart-bar">
<div class="bar-chart-fill" style="width: {{ p.bar_pct }}%; background: #22c55e;">
<span>{{ p.count }}</span>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Exit pages -->
<div class="section-card">
<h2>Strony wyjściowe (top 10)</h2>
{% for p in data.exit_pages %}
<div class="bar-chart-row">
<div class="bar-chart-label" style="width: 200px; min-width: 200px; font-family: monospace; font-size: var(--font-size-xs); word-break: break-all;">{{ p.path }}</div>
<div class="bar-chart-bar">
<div class="bar-chart-fill" style="width: {{ p.bar_pct }}%; background: #ef4444;">
<span>{{ p.count }}</span>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Top transitions -->
<div class="section-card">
<h2>Popularne przejścia (top 30)</h2>
<div class="table-scroll" style="max-height: 400px;">
<table class="data-table">
<thead>
<tr>
<th>Ze strony</th>
<th style="width: 40px;"></th>
<th>Na stronę</th>
<th style="width: 80px;">Liczba</th>
</tr>
</thead>
<tbody>
{% for t in data.transitions %}
<tr>
<td style="font-family: monospace; font-size: var(--font-size-xs);">{{ t.from }}</td>
<td class="transition-arrow" style="text-align: center;"></td>
<td style="font-family: monospace; font-size: var(--font-size-xs);">{{ t.to }}</td>
<td style="font-weight: 600; text-align: right;">{{ t.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="two-columns">
<!-- Drop-off pages -->
<div class="section-card">
<h2>Strony z odpływem</h2>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Ścieżka</th>
<th>Odsłony</th>
<th>Wyjścia</th>
<th>Exit rate</th>
</tr>
</thead>
<tbody>
{% for d in data.dropoff %}
<tr>
<td style="font-family: monospace; font-size: var(--font-size-xs);">{{ d.path }}</td>
<td>{{ d.views }}</td>
<td>{{ d.exits }}</td>
<td>
<span {% if d.exit_rate > 70 %}style="color: var(--error); font-weight: 600;"{% elif d.exit_rate > 50 %}style="color: #d97706;"{% endif %}>
{{ d.exit_rate }}%
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Session length distribution -->
<div class="section-card">
<h2>Rozkład długości sesji</h2>
{% for s in data.session_lengths %}
<div class="bar-chart-row">
<div class="bar-chart-label">{{ s.bucket }}</div>
<div class="bar-chart-bar">
<div class="bar-chart-fill" style="width: {{ s.bar_pct }}%;">
<span>{{ s.count }}</span>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- ============================================================ -->
<!-- TAB: OVERVIEW -->
<!-- ============================================================ -->
{% elif tab == 'overview' %}
<!-- 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>
<!-- ============================================================ -->
<!-- TAB: CHAT & CONVERSIONS -->
<!-- ============================================================ -->
{% elif tab == 'chat' %}
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card success">
<div class="stat-value">{{ data.total_conversations }}</div>
<div class="stat-label">Rozmów ogółem</div>
</div>
<div class="stat-card info">
<div class="stat-value">{{ data.period_conversations }}</div>
<div class="stat-label">Rozmów (okres)</div>
</div>
<div class="stat-card success">
<div class="stat-value">{{ data.positive_feedback }}</div>
<div class="stat-label">Pozytywnych ocen</div>
</div>
<div class="stat-card error">
<div class="stat-value">{{ data.negative_feedback }}</div>
<div class="stat-label">Negatywnych ocen</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</div>
</div>
</div>
<div class="two-columns">
<!-- Query Statistics (Anonymized) -->
<div class="section-card">
<h2>Statystyki zapytań <span style="font-size: var(--font-size-xs); color: var(--text-muted);">dane zanonimizowane</span></h2>
<div class="stats-grid" style="grid-template-columns: repeat(2, 1fr);">
<div class="stat-card">
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.query_stats.total_today }}</div>
<div class="stat-label">Zapytań dzisiaj</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.query_stats.avg_length|int }}</div>
<div class="stat-label">Śr. długość (znaki)</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.query_stats.queries_with_company }}</div>
<div class="stat-label">O firmach</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.query_stats.queries_with_contact }}</div>
<div class="stat-label">O kontaktach</div>
</div>
</div>
</div>
<!-- Conversion Events -->
<div class="section-card">
<h2>Konwersje z chatu</h2>
{% if data.conversion_stats %}
<div class="stats-grid" style="grid-template-columns: repeat(2, 1fr);">
{% for event_type, count in data.conversion_stats.items() %}
<div style="background: var(--background); padding: var(--spacing-md); border-radius: var(--radius); text-align: center;">
<div style="font-size: var(--font-size-xl); font-weight: 700; color: var(--primary);">{{ count }}</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary);">{{ event_type }}</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="text-align: center; padding: var(--spacing-xl); color: var(--text-muted);">
<p>Brak konwersji z chatu w wybranym okresie</p>
</div>
{% endif %}
</div>
</div>
<!-- Recent Feedback -->
{% if data.recent_feedback %}
<div class="section-card">
<h2>Odpowiedzi z oceną</h2>
<div class="table-scroll" style="max-height: 400px;">
{% for msg in data.recent_feedback %}
<div style="padding: var(--spacing-md); border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: flex-start; gap: var(--spacing-md);">
<div style="flex: 1;">
<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>
<div style="margin-top: var(--spacing-sm); font-size: var(--font-size-sm); color: var(--text-secondary); max-height: 60px; overflow: hidden;">
{{ msg.content[:200] }}{% if msg.content|length > 200 %}...{% endif %}
</div>
{% if msg.feedback_comment %}
<div style="margin-top: var(--spacing-xs); font-size: var(--font-size-xs);"><strong>Komentarz:</strong> {{ msg.feedback_comment }}</div>
{% endif %}
</div>
<div style="font-size: var(--font-size-xs); color: var(--text-muted); white-space: nowrap;">
{{ msg.feedback_at.strftime('%d.%m %H:%M') if msg.feedback_at else '' }}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Recent Queries (Anonymized) -->
{% if data.recent_queries %}
<div class="section-card">
<h2>Ostatnia aktywność <span style="font-size: var(--font-size-xs); color: var(--text-muted);">zanonimizowana</span></h2>
<div class="table-scroll" style="max-height: 350px;">
{% for query in data.recent_queries %}
<div style="padding: var(--spacing-sm) var(--spacing-md); border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; font-size: var(--font-size-sm);">
<div>
Zapytanie ({{ query.length }} znaków)
{% if query.has_company_mention %}
<span class="badge badge-low" style="margin-left: 4px;">FIRMA</span>
{% endif %}
{% if query.has_contact_request %}
<span class="badge badge-active" style="margin-left: 4px;">KONTAKT</span>
{% endif %}
</div>
<div style="color: var(--text-muted); font-size: var(--font-size-xs);">
{{ query.created_at.strftime('%d.%m %H:%M') }}
</div>
</div>
{% endfor %}
</div>
<div style="margin-top: var(--spacing-md); padding: var(--spacing-md); background: var(--background); border-radius: var(--radius); font-size: var(--font-size-xs); color: var(--text-secondary);">
Treść zapytań użytkowników nie jest wyświetlana w panelu admina. Widoczne są tylko zanonimizowane statystyki.
</div>
</div>
{% endif %}
<!-- UTM Sources -->
{% if data.utm_sources %}
<div class="section-card">
<h2>Źródła ruchu (UTM)</h2>
<div style="display: flex; flex-direction: column; gap: var(--spacing-sm);">
{% for source, count in data.utm_sources.items() %}
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-xs) 0; border-bottom: 1px solid var(--border);">
<span style="font-weight: 500;">{{ source }}</span>
<span class="badge badge-low">{{ count }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
</div>
{% if tab == 'overview' %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
{% endif %}
{% 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');
new Chart(authCtx, {
type: 'doughnut',
data: {
labels: ['Zalogowani', 'Anonimowi'],
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;
}
});
});
{% endblock %}