feat: add dashboard KPI widget, member goals, feature adoption, event comparison
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
1. Dashboard admin widget: compact KPI cards (active/total members with progress bar, sessions, security alerts, never-logged users) 2. Overview KPI: first card shows X/Y members with progress bar and % 3. Feature adoption chart: which portal modules are used by what % of members (NordaGPT, Forum, Search, Calendar, B2B, News, Companies) 4. Event comparison table: views, unique viewers, RSVP count per event in the last 60 days - helps plan better events Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c62e5827d6
commit
7aa8b8bdc3
@ -1223,6 +1223,81 @@ def _tab_overview(db, start_date, days):
|
||||
device_mobile = [device_map.get(w, {}).get('mobile', 0) for w in weeks_set]
|
||||
device_tablet = [device_map.get(w, {}).get('tablet', 0) for w in weeks_set]
|
||||
|
||||
# Goal: active members / total members
|
||||
total_members = db.query(func.count(User.id)).filter(User.is_active == True).scalar() or 0
|
||||
kpi['total_members'] = total_members
|
||||
kpi['active_pct'] = round(kpi['active_users'] / total_members * 100) if total_members > 0 else 0
|
||||
|
||||
# Feature adoption: unique logged users per section (30 days)
|
||||
feature_sections = {
|
||||
'NordaGPT': ['/chat'],
|
||||
'Forum': ['/forum'],
|
||||
'Wyszukiwarka': ['/search', '/szukaj'],
|
||||
'Kalendarz': ['/kalendarz', '/events'],
|
||||
'Tablica B2B': ['/tablica', '/classifieds'],
|
||||
'Aktualności': ['/ogloszenia', '/wiadomosci'],
|
||||
'Profile firm': ['/company/'],
|
||||
}
|
||||
adoption = []
|
||||
for feature_name, prefixes in feature_sections.items():
|
||||
conditions = [PageView.path.like(p + '%') for p in prefixes]
|
||||
users_count = db.query(func.count(func.distinct(PageView.user_id))).join(
|
||||
UserSession, PageView.session_id == UserSession.id
|
||||
).filter(
|
||||
or_(*conditions),
|
||||
PageView.viewed_at >= start_30d,
|
||||
PageView.user_id.isnot(None),
|
||||
UserSession.is_bot == False
|
||||
).scalar() or 0
|
||||
adoption.append({
|
||||
'name': feature_name,
|
||||
'users': users_count,
|
||||
'pct': round(users_count / total_members * 100) if total_members > 0 else 0,
|
||||
})
|
||||
adoption.sort(key=lambda x: x['users'], reverse=True)
|
||||
|
||||
# Event comparison (last 30 days - views, RSVP count per event)
|
||||
events_sql = text("""
|
||||
SELECT ne.id, ne.title, ne.event_date,
|
||||
COUNT(DISTINCT pv.id) as views,
|
||||
COUNT(DISTINCT pv.user_id) as unique_viewers
|
||||
FROM norda_events ne
|
||||
LEFT JOIN page_views pv ON pv.path = '/kalendarz/' || ne.id::text
|
||||
AND pv.viewed_at >= :start_dt
|
||||
LEFT JOIN user_sessions us ON pv.session_id = us.id AND us.is_bot = false
|
||||
WHERE ne.event_date >= :month_ago
|
||||
GROUP BY ne.id, ne.title, ne.event_date
|
||||
ORDER BY ne.event_date DESC
|
||||
""")
|
||||
events_raw = db.execute(events_sql, {
|
||||
'start_dt': start_30d,
|
||||
'month_ago': (date.today() - timedelta(days=60)).isoformat()
|
||||
}).fetchall()
|
||||
|
||||
# Get RSVP counts
|
||||
from database import EventAttendee
|
||||
event_ids = [r.id for r in events_raw]
|
||||
rsvp_map = {}
|
||||
if event_ids:
|
||||
rsvps = db.query(
|
||||
EventAttendee.event_id,
|
||||
func.count(EventAttendee.id)
|
||||
).filter(
|
||||
EventAttendee.event_id.in_(event_ids),
|
||||
EventAttendee.status == 'confirmed'
|
||||
).group_by(EventAttendee.event_id).all()
|
||||
rsvp_map = {eid: cnt for eid, cnt in rsvps}
|
||||
|
||||
events_comparison = []
|
||||
for r in events_raw:
|
||||
events_comparison.append({
|
||||
'title': r.title,
|
||||
'date': r.event_date.strftime('%d.%m.%Y') if r.event_date else '',
|
||||
'views': r.views or 0,
|
||||
'unique_viewers': r.unique_viewers or 0,
|
||||
'rsvp': rsvp_map.get(r.id, 0),
|
||||
})
|
||||
|
||||
return {
|
||||
'filter_type': filter_type,
|
||||
'kpi': kpi,
|
||||
@ -1239,6 +1314,8 @@ def _tab_overview(db, start_date, days):
|
||||
'mobile': device_mobile,
|
||||
'tablet': device_tablet,
|
||||
},
|
||||
'adoption': adoption,
|
||||
'events_comparison': events_comparison,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -750,8 +750,55 @@ def dashboard():
|
||||
Company.created_at.desc()
|
||||
).limit(3).all()
|
||||
|
||||
# Admin KPI widget (only computed for admins)
|
||||
admin_kpi = None
|
||||
if current_user.can_access_admin_panel():
|
||||
from sqlalchemy import func as sqlfunc
|
||||
from database import UserSession, PageView, SecurityAlert
|
||||
from datetime import timedelta as td
|
||||
now = datetime.now()
|
||||
week_ago = now - td(days=7)
|
||||
prev_week = now - td(days=14)
|
||||
|
||||
# Active members (logged in last 7d)
|
||||
active_7d = db.query(sqlfunc.count(sqlfunc.distinct(UserSession.user_id))).filter(
|
||||
UserSession.started_at >= week_ago,
|
||||
UserSession.user_id.isnot(None),
|
||||
UserSession.is_bot == False
|
||||
).scalar() or 0
|
||||
|
||||
# Total registered users
|
||||
total_users = db.query(sqlfunc.count(User.id)).filter(User.is_active == True).scalar() or 0
|
||||
|
||||
# Sessions this week
|
||||
sessions_7d = db.query(sqlfunc.count(UserSession.id)).filter(
|
||||
UserSession.started_at >= week_ago,
|
||||
UserSession.is_bot == False
|
||||
).scalar() or 0
|
||||
|
||||
# Active problems (security alerts + locked accounts)
|
||||
active_problems = db.query(sqlfunc.count(SecurityAlert.id)).filter(
|
||||
SecurityAlert.created_at >= week_ago
|
||||
).scalar() or 0
|
||||
|
||||
# Never logged in
|
||||
never_logged = db.query(sqlfunc.count(User.id)).filter(
|
||||
User.is_active == True,
|
||||
User.last_login.is_(None)
|
||||
).scalar() or 0
|
||||
|
||||
admin_kpi = {
|
||||
'active_users': active_7d,
|
||||
'total_users': total_users,
|
||||
'active_pct': round(active_7d / total_users * 100) if total_users > 0 else 0,
|
||||
'sessions': sessions_7d,
|
||||
'problems': active_problems,
|
||||
'never_logged': never_logged,
|
||||
}
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
admin_kpi=admin_kpi,
|
||||
conversations=conversations,
|
||||
total_conversations=total_conversations,
|
||||
total_messages=total_messages,
|
||||
|
||||
@ -772,8 +772,22 @@
|
||||
|
||||
<!-- 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 [
|
||||
('active_users', 'Aktywni użytkownicy', 'success', false),
|
||||
('sessions', 'Sesje', 'info', false),
|
||||
('pageviews', 'Odsłony', 'info', false),
|
||||
('bounce_rate', 'Współczynnik odrzuceń', 'info', true),
|
||||
@ -864,6 +878,57 @@
|
||||
</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>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- TAB: CHAT & CONVERSIONS -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
@ -891,6 +891,30 @@
|
||||
Funkcje administratora
|
||||
</h4>
|
||||
|
||||
{% if admin_kpi %}
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--spacing-sm, 12px); margin-bottom: var(--spacing-lg, 20px);">
|
||||
<div style="background: white; border-radius: 8px; padding: 12px 16px; border-left: 3px solid #10b981;">
|
||||
<div style="font-size: 1.5rem; font-weight: 700; color: var(--text-primary, #1e293b);">{{ admin_kpi.active_users }}<span style="font-size: 0.75rem; color: var(--text-muted, #94a3b8); font-weight: 400;">/{{ admin_kpi.total_users }}</span></div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted, #94a3b8);">Aktywni członkowie (7d)</div>
|
||||
<div style="margin-top: 4px; height: 4px; background: #e2e8f0; border-radius: 2px;">
|
||||
<div style="height: 100%; width: {{ admin_kpi.active_pct }}%; background: #10b981; border-radius: 2px; transition: width 0.5s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background: white; border-radius: 8px; padding: 12px 16px; border-left: 3px solid #6366f1;">
|
||||
<div style="font-size: 1.5rem; font-weight: 700; color: var(--text-primary, #1e293b);">{{ admin_kpi.sessions }}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted, #94a3b8);">Sesje (7 dni)</div>
|
||||
</div>
|
||||
<div style="background: white; border-radius: 8px; padding: 12px 16px; border-left: 3px solid {{ '#ef4444' if admin_kpi.problems > 0 else '#10b981' }};">
|
||||
<div style="font-size: 1.5rem; font-weight: 700; color: {{ '#ef4444' if admin_kpi.problems > 0 else 'var(--text-primary, #1e293b)' }};">{{ admin_kpi.problems }}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted, #94a3b8);">Alerty bezpieczeństwa</div>
|
||||
</div>
|
||||
<div style="background: white; border-radius: 8px; padding: 12px 16px; border-left: 3px solid {{ '#f59e0b' if admin_kpi.never_logged > 0 else '#10b981' }};">
|
||||
<div style="font-size: 1.5rem; font-weight: 700; color: {{ '#f59e0b' if admin_kpi.never_logged > 0 else 'var(--text-primary, #1e293b)' }};">{{ admin_kpi.never_logged }}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted, #94a3b8);">Nigdy nie zalogowani</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="admin-functions-grid">
|
||||
<a href="{{ url_for('admin.admin_recommendations') }}" class="admin-function-card">
|
||||
<svg fill="none" stroke="#f59e0b" stroke-width="2" viewBox="0 0 24 24">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user