nordabiz/templates/admin/user_insights_profile.html
Maciej Pienczyn 110d971dca
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
feat: migrate prod docs to OVH VPS + UTC→Warsaw timezone in all templates
Production moved from on-prem VM 249 (10.22.68.249) to OVH VPS
(57.128.200.27, inpi-vps-waw01). Updated ALL documentation, slash
commands, memory files, architecture docs, and deploy procedures.

Added |local_time Jinja filter (UTC→Europe/Warsaw) and converted
155 .strftime() calls across 71 templates so timestamps display
in Polish timezone regardless of server timezone.

Also includes: created_by_id tracking, abort import fix, ICS
calendar fix for missing end times, Pros Poland data cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:41:53 +02:00

484 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

{% extends "base.html" %}
{% block title %}{{ user.name or user.email }} - Analityka{% endblock %}
{% block head_extra %}
<script src="{{ url_for('static', filename='js/vendor/chart.min.js') }}"></script>
{% endblock %}
{% block extra_css %}
<style>
.profile-container { max-width: 1400px; margin: 0 auto; padding: var(--spacing-lg); }
/* Back link */
.back-link { display: inline-flex; align-items: center; gap: var(--spacing-xs); color: var(--text-secondary); text-decoration: none; font-size: var(--font-size-sm); margin-bottom: var(--spacing-lg); }
.back-link:hover { color: var(--primary); }
/* Profile header */
.profile-header { background: white; padding: var(--spacing-xl); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); margin-bottom: var(--spacing-xl); display: flex; gap: var(--spacing-xl); align-items: flex-start; flex-wrap: wrap; }
.profile-info { flex: 1; min-width: 250px; }
.profile-name { font-size: var(--font-size-2xl); font-weight: 700; margin-bottom: var(--spacing-xs); }
.profile-email { color: var(--text-secondary); font-size: var(--font-size-sm); margin-bottom: var(--spacing-md); }
.profile-meta { display: flex; gap: var(--spacing-lg); flex-wrap: wrap; font-size: var(--font-size-sm); color: var(--text-secondary); }
.profile-meta-item { display: flex; flex-direction: column; gap: 2px; }
.profile-meta-label { font-size: var(--font-size-xs); text-transform: uppercase; letter-spacing: 0.5px; }
.profile-meta-value { font-weight: 600; color: var(--text-primary); }
/* Gauges row */
.gauges-row { display: flex; gap: var(--spacing-xl); }
.gauge-wrapper { text-align: center; }
.gauge { position: relative; width: 120px; height: 60px; overflow: hidden; }
.gauge-bg { position: absolute; width: 120px; height: 120px; border-radius: 50%; border: 10px solid var(--border); border-bottom-color: transparent; border-left-color: transparent; transform: rotate(225deg); }
.gauge-fill { position: absolute; width: 120px; height: 120px; border-radius: 50%; border: 10px solid; border-bottom-color: transparent; border-left-color: transparent; transition: transform 0.8s ease-out; }
.gauge-fill.low { border-top-color: #22c55e; border-right-color: #22c55e; }
.gauge-fill.medium { border-top-color: #f59e0b; border-right-color: #f59e0b; }
.gauge-fill.high { border-top-color: #ef4444; border-right-color: #ef4444; }
.gauge-value { position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); font-size: var(--font-size-xl); font-weight: 700; }
.gauge-label { font-size: var(--font-size-xs); color: var(--text-secondary); 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); }
/* Two columns */
.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-xl); }
@media (max-width: 1024px) { .two-columns { grid-template-columns: 1fr; } }
/* Three columns */
.three-columns { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--spacing-xl); }
@media (max-width: 1024px) { .three-columns { grid-template-columns: 1fr; } }
/* Timeline */
.timeline { position: relative; padding-left: var(--spacing-xl); }
.timeline::before { content: ''; position: absolute; left: 12px; top: 0; bottom: 0; width: 2px; background: var(--border); }
.timeline-item { position: relative; padding-bottom: var(--spacing-md); display: flex; gap: var(--spacing-md); align-items: flex-start; }
.timeline-dot { width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-left: -30px; z-index: 1; font-size: 10px; }
.timeline-dot.login { background: #dbeafe; color: #2563eb; }
.timeline-dot.pageview { background: #dcfce7; color: #16a34a; }
.timeline-dot.search { background: #fef3c7; color: #d97706; }
.timeline-dot.conversion { background: #ede9fe; color: #7c3aed; }
.timeline-dot.problem { background: #fee2e2; color: #dc2626; }
.timeline-dot.email { background: #fef3c7; color: #d97706; }
.timeline-dot.info { background: #f3f4f6; color: #6b7280; }
.timeline-body { flex: 1; }
.timeline-desc { font-size: var(--font-size-sm); }
.timeline-desc.css-danger { color: #dc2626; font-weight: 600; }
.timeline-desc.css-warning { color: #d97706; }
.timeline-desc.css-success { color: #16a34a; }
.timeline-detail { font-size: var(--font-size-xs); color: var(--text-muted); margin-top: 1px; }
.timeline-time { font-size: var(--font-size-xs); color: var(--text-muted); }
/* Resolution status */
.resolution-card { padding: var(--spacing-lg); border-radius: var(--radius-lg); margin-bottom: var(--spacing-xl); }
.resolution-card.resolved { background: #f0fdf4; border: 1px solid #86efac; }
.resolution-card.pending { background: #fffbeb; border: 1px solid #fcd34d; }
.resolution-card.blocked { background: #fef2f2; border: 1px solid #fca5a5; }
.resolution-card.unresolved { background: #fef2f2; border: 1px solid #fca5a5; }
.resolution-card.unknown { background: #f9fafb; border: 1px solid #e5e7eb; }
.resolution-header { display: flex; align-items: center; gap: var(--spacing-md); margin-bottom: var(--spacing-md); }
.resolution-status { font-size: var(--font-size-lg); font-weight: 700; }
.resolution-card.resolved .resolution-status { color: #16a34a; }
.resolution-card.pending .resolution-status { color: #d97706; }
.resolution-card.blocked .resolution-status, .resolution-card.unresolved .resolution-status { color: #dc2626; }
.resolution-details { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--spacing-md); }
.resolution-detail-item { font-size: var(--font-size-sm); }
.resolution-detail-label { font-size: var(--font-size-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; }
.resolution-detail-value { font-weight: 600; margin-top: 2px; }
/* Bars */
.bar-row { display: flex; align-items: center; gap: var(--spacing-sm); margin-bottom: var(--spacing-sm); }
.bar-label { width: 200px; min-width: 200px; font-size: var(--font-size-xs); font-family: monospace; word-break: break-all; color: var(--text-secondary); }
.bar-track { flex: 1; height: 20px; background: var(--background); border-radius: var(--radius-sm); overflow: hidden; }
.bar-fill { height: 100%; border-radius: var(--radius-sm); background: var(--primary); }
.bar-value { font-size: var(--font-size-xs); font-weight: 600; min-width: 30px; text-align: right; }
/* Hourly bars */
.hourly-bars { display: flex; align-items: flex-end; gap: 2px; height: 60px; }
.hourly-bar { flex: 1; background: var(--primary); border-radius: 2px 2px 0 0; min-width: 8px; opacity: 0.7; position: relative; }
.hourly-bar:hover { opacity: 1; }
.hourly-labels { display: flex; gap: 2px; }
.hourly-label { flex: 1; text-align: center; font-size: 9px; color: var(--text-muted); min-width: 8px; }
/* 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); }
.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; }
/* Stats grid */
.stats-mini { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--spacing-md); margin-bottom: var(--spacing-lg); }
.stat-mini { text-align: center; padding: var(--spacing-md); background: var(--background); border-radius: var(--radius); }
.stat-mini-value { font-size: var(--font-size-xl); font-weight: 700; }
.stat-mini-label { font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 2px; }
/* Chart */
.chart-container { position: relative; height: 250px; }
/* 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); vertical-align: middle; }
.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; }
@media (max-width: 768px) {
.profile-header { flex-direction: column; }
.gauges-row { flex-direction: row; gap: var(--spacing-md); }
}
</style>
{% endblock %}
{% block content %}
<div class="profile-container">
<a href="{{ url_for('admin.user_insights', tab=ref_tab, period=ref_period) }}" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Powrót do Analityki
</a>
<!-- Profile Header -->
<div class="profile-header">
<div class="profile-info">
<div class="profile-name">{{ user.name or 'Bez nazwy' }}</div>
<div class="profile-email">
{{ user.email }}
<button class="btn-reset-pw" data-uid="{{ user.id }}" data-name="{{ user.name or user.email }}" data-email="{{ user.email }}" style="margin-left: 12px;">📧 Wyślij reset hasła</button>
</div>
<div class="profile-meta">
{% if user.company %}
<div class="profile-meta-item">
<span class="profile-meta-label">Firma</span>
<span class="profile-meta-value">{{ user.company.name }}</span>
</div>
{% endif %}
<div class="profile-meta-item">
<span class="profile-meta-label">Rola</span>
<span class="profile-meta-value">{{ user.role }}</span>
</div>
<div class="profile-meta-item">
<span class="profile-meta-label">Rejestracja</span>
<span class="profile-meta-value">{{ user.created_at|local_time('%d.%m.%Y') if user.created_at else 'N/A' }}</span>
</div>
<div class="profile-meta-item">
<span class="profile-meta-label">Ostatni login</span>
<span class="profile-meta-value">{{ user.last_login|local_time('%d.%m.%Y %H:%M') if user.last_login else 'Nigdy' }}</span>
</div>
</div>
</div>
<div class="gauges-row">
<!-- Engagement gauge -->
<div class="gauge-wrapper">
<div class="gauge">
<div class="gauge-bg"></div>
{% set eng_class = 'low' if engagement_score >= 50 else ('medium' if engagement_score >= 20 else 'high') %}
<div class="gauge-fill {{ eng_class }}" style="transform: rotate({{ 225 + (engagement_score / 100 * 180) }}deg);"></div>
<div class="gauge-value">{{ engagement_score }}</div>
</div>
<div class="gauge-label">Zaangażowanie</div>
</div>
<!-- Problem gauge -->
<div class="gauge-wrapper">
<div class="gauge">
<div class="gauge-bg"></div>
{% set prob_class = 'high' if problem_score >= 51 else ('medium' if problem_score >= 21 else 'low') %}
<div class="gauge-fill {{ prob_class }}" style="transform: rotate({{ 225 + (problem_score / 100 * 180) }}deg);"></div>
<div class="gauge-value">{{ problem_score }}</div>
</div>
<div class="gauge-label">Problemy</div>
</div>
</div>
</div>
<!-- Quick stats -->
<div class="stats-mini">
<div class="stat-mini">
<div class="stat-mini-value">{{ avg_sessions_week }}</div>
<div class="stat-mini-label">Śr. sesji/tydzień</div>
</div>
<div class="stat-mini">
<div class="stat-mini-value">{{ (avg_session_duration / 60)|round(1) }}m</div>
<div class="stat-mini-label">Śr. czas sesji</div>
</div>
<div class="stat-mini">
<div class="stat-mini-value">{{ password_resets }}</div>
<div class="stat-mini-label">Resety hasła (30d)</div>
</div>
<div class="stat-mini">
<div class="stat-mini-value">{{ security_alerts_count }}</div>
<div class="stat-mini-label">Alerty (7d)</div>
</div>
</div>
<!-- Resolution status -->
{% if resolution %}
<div class="resolution-card {{ resolution.status }}">
<div class="resolution-header">
{% if resolution.status == 'resolved' %}✅{% elif resolution.status == 'pending' %}⏳{% elif resolution.status == 'blocked' %}🔒{% elif resolution.status == 'unresolved' %}❌{% else %}❓{% endif %}
<div class="resolution-status">{{ resolution.status_label }}</div>
</div>
<div class="resolution-details">
{% if resolution.first_symptom %}
<div class="resolution-detail-item">
<div class="resolution-detail-label">Pierwszy objaw</div>
<div class="resolution-detail-value">{{ resolution.first_symptom|local_time('%d.%m.%Y %H:%M') }}</div>
</div>
{% endif %}
<div class="resolution-detail-item">
<div class="resolution-detail-label">Resety hasła wysłane</div>
<div class="resolution-detail-value">{{ resolution.resets_sent }}</div>
</div>
{% if resolution.last_reset %}
<div class="resolution-detail-item">
<div class="resolution-detail-label">Ostatni reset</div>
<div class="resolution-detail-value">{{ resolution.last_reset|local_time('%d.%m.%Y %H:%M') }}</div>
</div>
{% endif %}
<div class="resolution-detail-item">
<div class="resolution-detail-label">Zalogowano po resecie</div>
<div class="resolution-detail-value">{{ 'Tak' if resolution.login_after_reset else 'Nie' }}</div>
</div>
<div class="resolution-detail-item">
<div class="resolution-detail-label">Aktywny token</div>
<div class="resolution-detail-value">{{ 'Tak' if resolution.has_active_token else 'Nie' }}</div>
</div>
<div class="resolution-detail-item">
<div class="resolution-detail-label">Email powitalny</div>
<div class="resolution-detail-value">{{ 'Tak' if resolution.has_welcome_email else 'NIE WYSŁANO' }}</div>
</div>
{% if resolution.duration %}
<div class="resolution-detail-item">
<div class="resolution-detail-label">Czas do rozwiązania</div>
<div class="resolution-detail-value">{{ resolution.duration }}</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
<div class="two-columns">
<!-- Timeline -->
<div class="section-card">
<h2>Oś czasu aktywności</h2>
{% if timeline %}
{% if timeline|length >= 150 %}
<p style="color: var(--text-muted); text-align: center; font-size: 0.8rem; margin-bottom: var(--spacing-sm);">
Wyświetlono ostatnie 150 zdarzeń. Starsze zdarzenia są ukryte.
</p>
{% endif %}
<div class="timeline" style="max-height: 600px; overflow-y: auto;">
{% for event in timeline %}
<div class="timeline-item">
<div class="timeline-dot {{ event.type }}">
{% if event.icon == 'key' %}🔑{% elif event.icon == 'eye' %}👁{% elif event.icon == 'search' %}🔍{% elif event.icon == 'check' %}✓{% elif event.icon == 'alert' %}⚠{% elif event.icon == 'shield' %}🛡{% elif event.icon == 'x' %}✗{% elif event.icon == 'mail' %}📧{% elif event.icon == 'monitor' %}🖥{% elif event.icon == 'logout' %}🚪{% elif event.icon == 'user' %}👤{% elif event.icon == 'info' %}{% else %}•{% endif %}
</div>
<div class="timeline-body">
<div class="timeline-desc {% if event.css == 'danger' %}css-danger{% elif event.css == 'warning' %}css-warning{% elif event.css == 'success' %}css-success{% endif %}">{{ event.desc }}</div>
{% if event.detail %}<div class="timeline-detail">{{ event.detail }}</div>{% endif %}
<div class="timeline-time">{{ event.time|local_time('%d.%m.%Y %H:%M') }}</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p style="color: var(--text-muted); text-align: center;">Brak zdarzeń.</p>
{% endif %}
</div>
<!-- Behavioral stats -->
<div>
<!-- Favorite pages -->
<div class="section-card">
<h2>Ulubione strony (30d)</h2>
{% for p in fav_pages %}
<div class="bar-row">
<div class="bar-label">{{ p.path }}</div>
<div class="bar-track">
<div class="bar-fill" style="width: {{ p.bar_pct }}%;"></div>
</div>
<div class="bar-value">{{ p.count }}</div>
</div>
{% endfor %}
{% if not fav_pages %}
<p style="color: var(--text-muted); text-align: center;">Brak danych.</p>
{% endif %}
</div>
<!-- Hourly pattern -->
<div class="section-card">
<h2>Wzorzec godzinowy (30d)</h2>
<div class="hourly-bars">
{% set max_h = hourly_bars|map(attribute='count')|max if hourly_bars|map(attribute='count')|max > 0 else 1 %}
{% for h in hourly_bars %}
<div class="hourly-bar" style="height: {{ (h.count / max_h * 55 + 5)|int }}px;" title="{{ h.hour }}:00 — {{ h.count }}"></div>
{% endfor %}
</div>
<div class="hourly-labels">
{% for h in hourly_bars %}
<div class="hourly-label">{% if h.hour % 3 == 0 %}{{ h.hour }}{% endif %}</div>
{% endfor %}
</div>
</div>
<!-- Devices & Browsers -->
<div class="section-card">
<h2>Urządzenia i przeglądarki</h2>
<div style="display: flex; gap: var(--spacing-xl); flex-wrap: wrap;">
<div style="flex: 1; min-width: 120px;">
<strong style="font-size: var(--font-size-xs); color: var(--text-secondary);">URZĄDZENIA</strong>
{% for d in devices %}
<div style="margin-top: var(--spacing-sm); font-size: var(--font-size-sm);">
{{ d.type|capitalize }}: <strong>{{ d.count }}</strong>
</div>
{% endfor %}
</div>
<div style="flex: 1; min-width: 120px;">
<strong style="font-size: var(--font-size-xs); color: var(--text-secondary);">PRZEGLĄDARKI</strong>
{% for b in browsers %}
<div style="margin-top: var(--spacing-sm); font-size: var(--font-size-sm);">
{{ b.name }}: <strong>{{ b.count }}</strong>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<!-- Problem history -->
<div class="section-card">
<h2>Historia problemów</h2>
<div class="two-columns">
<div>
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin-bottom: var(--spacing-md);">Błędy JS</h3>
{% if js_errors %}
<table class="data-table">
<thead>
<tr><th>Wiadomość</th><th>Strona</th><th>Data</th></tr>
</thead>
<tbody>
{% for e in js_errors %}
<tr>
<td style="max-width: 300px; word-break: break-all; font-size: var(--font-size-xs);">{{ e.message[:100] }}</td>
<td style="font-size: var(--font-size-xs);">{{ e.url[:50] if e.url else 'N/A' }}</td>
<td style="white-space: nowrap;">{{ e.occurred_at|local_time('%d.%m %H:%M') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: var(--text-muted);">Brak błędów JS.</p>
{% endif %}
</div>
<div>
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin-bottom: var(--spacing-md);">Wolne strony (> 3s)</h3>
{% if slow_pages %}
<table class="data-table">
<thead>
<tr><th>Strona</th><th>Czas</th><th>Data</th></tr>
</thead>
<tbody>
{% for p in slow_pages %}
<tr>
<td style="font-family: monospace; font-size: var(--font-size-xs);">{{ p.path }}</td>
<td style="color: var(--error); font-weight: 600;">{{ p.load_time_ms }}ms</td>
<td style="white-space: nowrap;">{{ p.viewed_at|local_time('%d.%m %H:%M') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: var(--text-muted);">Brak wolnych stron.</p>
{% endif %}
</div>
</div>
</div>
<!-- Engagement trend chart -->
<div class="section-card">
<h2>Trend zaangażowania (30 dni)</h2>
<div class="chart-container">
<canvas id="trendChart"></canvas>
</div>
</div>
<!-- Search queries -->
{% if search_queries %}
<div class="section-card">
<h2>Ostatnie wyszukiwania</h2>
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing-sm);">
{% for s in search_queries %}
<span style="padding: 4px 12px; background: var(--background); border-radius: var(--radius); font-size: var(--font-size-sm);">
"{{ s.query }}" <span style="color: var(--text-muted); font-size: var(--font-size-xs);">{{ s.searched_at|local_time('%d.%m') }}</span>
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
const trendData = {{ trend_data|tojson|safe }};
const trendCtx = document.getElementById('trendChart').getContext('2d');
new Chart(trendCtx, {
type: 'line',
data: {
labels: trendData.labels,
datasets: [{
label: 'Dzienny score',
data: trendData.scores,
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { display: false } },
y: { beginAtZero: true, suggestedMax: Math.max(30, ...trendData.scores) + 5 }
}
}
});
// Password reset button
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 %}