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
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>
1123 lines
59 KiB
HTML
1123 lines
59 KiB
HTML
{% 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;">▶</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. Uwzględnia: sesje, odsłony stron, kliknięcia, czas na stronie, konwersje (RSVP, kontakt), wyszukiwania. 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: {{ 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) = {{ 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 · {{ 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 %}
|