feat: Add forum admin tools (analytics, search, move, merge)

New admin features:
- Analytics dashboard with stats, charts (Chart.js), user rankings
- CSV export of forum activity with date range
- Topic category move functionality
- Merge multiple topics into one
- Admin search across all posts (including deleted)
- User activity log with stats

New endpoints:
- GET /admin/forum/analytics
- GET /admin/forum/export-activity
- POST /admin/forum/topic/<id>/move
- POST /admin/forum/merge-topics
- GET /admin/forum/search
- GET /admin/forum/user/<id>/activity

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-31 19:29:02 +01:00
parent c5f724f954
commit e8586b3e4e
4 changed files with 1606 additions and 1 deletions

View File

@ -217,6 +217,13 @@ def register_blueprints(app):
'admin_deleted_content': 'forum.admin_deleted_content',
'user_forum_stats': 'forum.user_forum_stats',
'admin_forum_bulk_action': 'forum.admin_forum_bulk_action',
# Admin analytics & tools
'admin_forum_analytics': 'forum.admin_forum_analytics',
'admin_forum_export_activity': 'forum.admin_forum_export_activity',
'admin_move_topic': 'forum.admin_move_topic',
'admin_merge_topics': 'forum.admin_merge_topics',
'admin_forum_search': 'forum.admin_forum_search',
'admin_user_forum_activity': 'forum.admin_user_forum_activity',
})
logger.info("Created forum endpoint aliases")
except ImportError as e:

View File

@ -1517,3 +1517,624 @@ def user_forum_stats(user_id):
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
# ============================================================
# ADMIN ANALYTICS & TOOLS
# ============================================================
@bp.route('/admin/forum/analytics')
@login_required
def admin_forum_analytics():
"""Forum analytics dashboard with stats, charts, and rankings"""
if not current_user.is_admin:
flash('Brak uprawnien do tej strony.', 'error')
return redirect(url_for('.forum_index'))
from sqlalchemy import func, distinct
from datetime import timedelta
# Date range from query params (default: last 30 days)
end_date_str = request.args.get('end_date', datetime.now().strftime('%Y-%m-%d'))
start_date_str = request.args.get('start_date', (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d'))
try:
end_date = datetime.strptime(end_date_str, '%Y-%m-%d')
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
except ValueError:
end_date = datetime.now()
start_date = end_date - timedelta(days=30)
# Ensure end_date is end of day
end_date = end_date.replace(hour=23, minute=59, second=59)
db = SessionLocal()
try:
# Basic stats
total_topics = db.query(func.count(ForumTopic.id)).filter(
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
).scalar() or 0
total_replies = db.query(func.count(ForumReply.id)).filter(
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
).scalar() or 0
# This month stats
month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
topics_this_month = db.query(func.count(ForumTopic.id)).filter(
ForumTopic.created_at >= month_start,
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
).scalar() or 0
replies_this_month = db.query(func.count(ForumReply.id)).filter(
ForumReply.created_at >= month_start,
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
).scalar() or 0
# Active users in last 7 days
week_ago = datetime.now() - timedelta(days=7)
active_topic_authors = db.query(distinct(ForumTopic.author_id)).filter(
ForumTopic.created_at >= week_ago
).count()
active_reply_authors = db.query(distinct(ForumReply.author_id)).filter(
ForumReply.created_at >= week_ago
).count()
# Unique active users (simplified - just sum for now)
active_users_7d = max(active_topic_authors, active_reply_authors)
# Unanswered topics count
unanswered_count = db.query(func.count(ForumTopic.id)).filter(
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)),
~ForumTopic.id.in_(
db.query(distinct(ForumReply.topic_id)).filter(
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
)
)
).scalar() or 0
# Resolved topics
resolved_count = db.query(func.count(ForumTopic.id)).filter(
ForumTopic.status == 'resolved',
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
).scalar() or 0
# Average response time (simplified - in hours)
# For now, just show "< 24h" as placeholder
avg_response_time = "< 24h"
stats = {
'total_topics': total_topics,
'total_replies': total_replies,
'topics_this_month': topics_this_month,
'replies_this_month': replies_this_month,
'active_users_7d': active_users_7d,
'unanswered_topics': unanswered_count,
'resolved_topics': resolved_count,
'avg_response_time': avg_response_time
}
# Unanswered topics list
replied_topic_ids = db.query(distinct(ForumReply.topic_id)).filter(
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
).subquery()
unanswered_topics = db.query(ForumTopic).filter(
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)),
~ForumTopic.id.in_(replied_topic_ids)
).order_by(ForumTopic.created_at.asc()).limit(20).all()
# Add days_waiting attribute
now = datetime.now()
for topic in unanswered_topics:
topic.days_waiting = (now - topic.created_at).days
# User rankings
user_stats = {}
# Count topics per user
topic_counts = db.query(
ForumTopic.author_id,
func.count(ForumTopic.id).label('count')
).filter(
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
).group_by(ForumTopic.author_id).all()
for author_id, count in topic_counts:
if author_id not in user_stats:
user_stats[author_id] = {'topic_count': 0, 'reply_count': 0, 'solution_count': 0, 'reaction_count': 0}
user_stats[author_id]['topic_count'] = count
# Count replies per user
reply_counts = db.query(
ForumReply.author_id,
func.count(ForumReply.id).label('count')
).filter(
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
).group_by(ForumReply.author_id).all()
for author_id, count in reply_counts:
if author_id not in user_stats:
user_stats[author_id] = {'topic_count': 0, 'reply_count': 0, 'solution_count': 0, 'reaction_count': 0}
user_stats[author_id]['reply_count'] = count
# Count solutions per user
solution_counts = db.query(
ForumReply.author_id,
func.count(ForumReply.id).label('count')
).filter(
ForumReply.is_solution == True,
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
).group_by(ForumReply.author_id).all()
for author_id, count in solution_counts:
if author_id in user_stats:
user_stats[author_id]['solution_count'] = count
# Get user details and calculate total score
user_rankings = []
user_ids = list(user_stats.keys())
if user_ids:
users = db.query(User).filter(User.id.in_(user_ids)).all()
user_map = {u.id: u for u in users}
for user_id, stats_data in user_stats.items():
if user_id in user_map:
user = user_map[user_id]
total_score = (
stats_data['topic_count'] * 2 +
stats_data['reply_count'] +
stats_data['solution_count'] * 5
)
user_rankings.append({
'id': user_id,
'name': user.name,
'email': user.email,
'topic_count': stats_data['topic_count'],
'reply_count': stats_data['reply_count'],
'solution_count': stats_data['solution_count'],
'reaction_count': stats_data['reaction_count'],
'total_score': total_score
})
user_rankings.sort(key=lambda x: x['total_score'], reverse=True)
user_rankings = user_rankings[:20] # Top 20
# Category stats
category_stats = {}
for cat in ForumTopic.CATEGORIES:
count = db.query(func.count(ForumTopic.id)).filter(
ForumTopic.category == cat,
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
).scalar() or 0
category_stats[cat] = count
# Chart data (activity per day)
chart_labels = []
chart_topics = []
chart_replies = []
current_date = start_date
while current_date <= end_date:
date_str = current_date.strftime('%d.%m')
chart_labels.append(date_str)
day_start = current_date.replace(hour=0, minute=0, second=0, microsecond=0)
day_end = current_date.replace(hour=23, minute=59, second=59, microsecond=999999)
topics_count = db.query(func.count(ForumTopic.id)).filter(
ForumTopic.created_at >= day_start,
ForumTopic.created_at <= day_end,
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
).scalar() or 0
chart_topics.append(topics_count)
replies_count = db.query(func.count(ForumReply.id)).filter(
ForumReply.created_at >= day_start,
ForumReply.created_at <= day_end,
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
).scalar() or 0
chart_replies.append(replies_count)
current_date += timedelta(days=1)
chart_data = {
'labels': chart_labels,
'topics': chart_topics,
'replies': chart_replies
}
return render_template(
'admin/forum_analytics.html',
stats=stats,
unanswered_topics=unanswered_topics,
user_rankings=user_rankings,
category_stats=category_stats,
chart_data=chart_data,
start_date=start_date_str,
end_date=end_date_str,
category_labels=ForumTopic.CATEGORY_LABELS
)
finally:
db.close()
@bp.route('/admin/forum/export-activity')
@login_required
def admin_forum_export_activity():
"""Export forum activity to CSV"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403
from flask import Response
from datetime import timedelta
import csv
import io
# Date range from query params
end_date_str = request.args.get('end_date', datetime.now().strftime('%Y-%m-%d'))
start_date_str = request.args.get('start_date', (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d'))
try:
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
except ValueError:
end_date = datetime.now()
start_date = end_date - timedelta(days=30)
db = SessionLocal()
try:
# Get all topics in date range
topics = db.query(ForumTopic).filter(
ForumTopic.created_at >= start_date,
ForumTopic.created_at <= end_date
).order_by(ForumTopic.created_at.desc()).all()
# Get all replies in date range
replies = db.query(ForumReply).filter(
ForumReply.created_at >= start_date,
ForumReply.created_at <= end_date
).order_by(ForumReply.created_at.desc()).all()
# Create CSV
output = io.StringIO()
writer = csv.writer(output)
# Header
writer.writerow([
'Typ', 'ID', 'Tytul/Temat', 'Autor', 'Email', 'Kategoria',
'Status', 'Data utworzenia', 'Odpowiedzi', 'Wyswietlenia'
])
# Topics
for topic in topics:
reply_count = len([r for r in topic.replies if not r.is_deleted])
writer.writerow([
'Temat',
topic.id,
topic.title,
topic.author.name or topic.author.email.split('@')[0],
topic.author.email,
ForumTopic.CATEGORY_LABELS.get(topic.category, topic.category),
ForumTopic.STATUS_LABELS.get(topic.status, topic.status),
topic.created_at.strftime('%Y-%m-%d %H:%M'),
reply_count,
topic.views_count or 0
])
# Replies
for reply in replies:
writer.writerow([
'Odpowiedz',
reply.id,
f'Re: {reply.topic.title}' if reply.topic else f'Temat #{reply.topic_id}',
reply.author.name or reply.author.email.split('@')[0],
reply.author.email,
'',
'Rozwiazanie' if reply.is_solution else '',
reply.created_at.strftime('%Y-%m-%d %H:%M'),
'',
''
])
output.seek(0)
filename = f'forum_activity_{start_date_str}_{end_date_str}.csv'
return Response(
output.getvalue(),
mimetype='text/csv',
headers={
'Content-Disposition': f'attachment; filename={filename}',
'Content-Type': 'text/csv; charset=utf-8'
}
)
finally:
db.close()
@bp.route('/admin/forum/topic/<int:topic_id>/move', methods=['POST'])
@login_required
def admin_move_topic(topic_id):
"""Move topic to different category"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403
data = request.get_json() or {}
new_category = data.get('category')
if not new_category or new_category not in ForumTopic.CATEGORIES:
return jsonify({'success': False, 'error': 'Nieprawidlowa kategoria'}), 400
db = SessionLocal()
try:
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
if not topic:
return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404
old_category = topic.category
topic.category = new_category
db.commit()
logger.info(f"Admin {current_user.email} moved topic #{topic_id} from {old_category} to {new_category}")
return jsonify({
'success': True,
'message': f"Temat przeniesiony do: {ForumTopic.CATEGORY_LABELS.get(new_category, new_category)}",
'old_category': old_category,
'new_category': new_category
})
finally:
db.close()
@bp.route('/admin/forum/merge-topics', methods=['POST'])
@login_required
def admin_merge_topics():
"""Merge multiple topics into one"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403
data = request.get_json() or {}
target_id = data.get('target_id')
source_ids = data.get('source_ids', [])
if not target_id or not source_ids:
return jsonify({'success': False, 'error': 'Brak wymaganych parametrow'}), 400
# Ensure target is not in sources
source_ids = [sid for sid in source_ids if sid != target_id]
if not source_ids:
return jsonify({'success': False, 'error': 'Brak tematow do polaczenia'}), 400
db = SessionLocal()
try:
# Get target topic
target = db.query(ForumTopic).filter(ForumTopic.id == target_id).first()
if not target:
return jsonify({'success': False, 'error': 'Temat docelowy nie istnieje'}), 404
# Get source topics
sources = db.query(ForumTopic).filter(ForumTopic.id.in_(source_ids)).all()
if not sources:
return jsonify({'success': False, 'error': 'Nie znaleziono tematow zrodlowych'}), 404
merged_count = 0
for source in sources:
# Create a reply from the source topic content
merge_note = ForumReply(
topic_id=target_id,
author_id=source.author_id,
content=f"[Polaczony temat: {source.title}]\n\n{source.content}",
created_at=source.created_at
)
db.add(merge_note)
# Move all replies from source to target
for reply in source.replies:
reply.topic_id = target_id
# Soft-delete the source topic
source.is_deleted = True
source.deleted_at = datetime.now()
source.deleted_by = current_user.id
merged_count += 1
# Update target topic timestamp
target.updated_at = datetime.now()
db.commit()
logger.info(f"Admin {current_user.email} merged {merged_count} topics into #{target_id}")
return jsonify({
'success': True,
'message': f"Polaczono {merged_count} tematow",
'merged_count': merged_count,
'target_id': target_id
})
except Exception as e:
db.rollback()
logger.error(f"Error merging topics: {e}")
return jsonify({'success': False, 'error': 'Wystapil blad podczas laczenia'}), 500
finally:
db.close()
@bp.route('/admin/forum/search')
@login_required
def admin_forum_search():
"""Search all forum content (including deleted) - admin only"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403
query = request.args.get('q', '').strip()
include_deleted = request.args.get('deleted') == '1'
if not query or len(query) < 2:
return jsonify({
'success': True,
'results': [],
'count': 0,
'message': 'Wpisz co najmniej 2 znaki'
})
db = SessionLocal()
try:
search_term = f'%{query}%'
results = []
# Search topics
topic_query = db.query(ForumTopic).filter(
(ForumTopic.title.ilike(search_term)) |
(ForumTopic.content.ilike(search_term))
)
if not include_deleted:
topic_query = topic_query.filter(
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
)
topics = topic_query.order_by(ForumTopic.created_at.desc()).limit(50).all()
for topic in topics:
results.append({
'type': 'topic',
'id': topic.id,
'title': topic.title,
'content_preview': topic.content[:150] + '...' if len(topic.content) > 150 else topic.content,
'author_name': topic.author.name or topic.author.email.split('@')[0],
'author_id': topic.author_id,
'created_at': topic.created_at.isoformat(),
'is_deleted': topic.is_deleted or False,
'category': topic.category,
'category_label': ForumTopic.CATEGORY_LABELS.get(topic.category, topic.category),
'url': url_for('.forum_topic', topic_id=topic.id)
})
# Search replies
reply_query = db.query(ForumReply).filter(
ForumReply.content.ilike(search_term)
)
if not include_deleted:
reply_query = reply_query.filter(
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
)
replies = reply_query.order_by(ForumReply.created_at.desc()).limit(50).all()
for reply in replies:
results.append({
'type': 'reply',
'id': reply.id,
'title': f'Re: {reply.topic.title}' if reply.topic else f'Odpowiedz #{reply.id}',
'content_preview': reply.content[:150] + '...' if len(reply.content) > 150 else reply.content,
'author_name': reply.author.name or reply.author.email.split('@')[0],
'author_id': reply.author_id,
'created_at': reply.created_at.isoformat(),
'is_deleted': reply.is_deleted or False,
'topic_id': reply.topic_id,
'url': url_for('.forum_topic', topic_id=reply.topic_id) + f'#reply-{reply.id}'
})
# Sort by date
results.sort(key=lambda x: x['created_at'], reverse=True)
return jsonify({
'success': True,
'results': results[:100], # Limit to 100 total results
'count': len(results),
'query': query
})
finally:
db.close()
@bp.route('/admin/forum/user/<int:user_id>/activity')
@login_required
def admin_user_forum_activity(user_id):
"""Get detailed forum activity for a specific user - admin only"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403
from sqlalchemy import func
db = SessionLocal()
try:
# Get user
user = db.query(User).filter(User.id == user_id).first()
if not user:
return jsonify({'success': False, 'error': 'Uzytkownik nie istnieje'}), 404
# Get user's topics
topics = db.query(ForumTopic).filter(
ForumTopic.author_id == user_id
).order_by(ForumTopic.created_at.desc()).limit(20).all()
# Get user's replies
replies = db.query(ForumReply).filter(
ForumReply.author_id == user_id
).order_by(ForumReply.created_at.desc()).limit(30).all()
# Stats
topic_count = db.query(func.count(ForumTopic.id)).filter(
ForumTopic.author_id == user_id,
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
).scalar() or 0
reply_count = db.query(func.count(ForumReply.id)).filter(
ForumReply.author_id == user_id,
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
).scalar() or 0
solution_count = db.query(func.count(ForumReply.id)).filter(
ForumReply.author_id == user_id,
ForumReply.is_solution == True,
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
).scalar() or 0
# Build activity log
activity = []
for topic in topics:
activity.append({
'type': 'topic',
'action': 'Utworzyl temat',
'title': topic.title,
'id': topic.id,
'created_at': topic.created_at.isoformat(),
'is_deleted': topic.is_deleted or False,
'category': topic.category,
'url': url_for('.forum_topic', topic_id=topic.id)
})
for reply in replies:
activity.append({
'type': 'reply',
'action': 'Odpowiedzial w' if reply.is_solution else 'Odpowiedzial w',
'title': reply.topic.title if reply.topic else f'Temat #{reply.topic_id}',
'id': reply.id,
'topic_id': reply.topic_id,
'created_at': reply.created_at.isoformat(),
'is_deleted': reply.is_deleted or False,
'is_solution': reply.is_solution or False,
'url': url_for('.forum_topic', topic_id=reply.topic_id) + f'#reply-{reply.id}'
})
# Sort by date
activity.sort(key=lambda x: x['created_at'], reverse=True)
return jsonify({
'success': True,
'user': {
'id': user.id,
'name': user.name or user.email.split('@')[0],
'email': user.email
},
'stats': {
'topics': topic_count,
'replies': reply_count,
'solutions': solution_count,
'total_posts': topic_count + reply_count
},
'activity': activity[:50] # Limit to 50 most recent
})
finally:
db.close()

View File

@ -414,7 +414,11 @@
</div>
<!-- Quick Actions -->
<div style="display: flex; gap: var(--spacing-md); margin-bottom: var(--spacing-xl);">
<div style="display: flex; gap: var(--spacing-md); margin-bottom: var(--spacing-xl); flex-wrap: wrap;">
<a href="{{ url_for('admin_forum_analytics') }}" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: var(--spacing-xs);">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
Analityka
</a>
<a href="{{ url_for('admin_forum_reports') }}" class="btn btn-outline" style="display: inline-flex; align-items: center; gap: var(--spacing-xs);">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"/></svg>
Zgłoszenia
@ -425,6 +429,25 @@
</a>
</div>
<!-- Admin Search Section -->
<div class="section" style="margin-bottom: var(--spacing-xl);">
<h2 style="margin-bottom: var(--spacing-md);">Wyszukiwarka</h2>
<div style="display: flex; gap: var(--spacing-md); align-items: center; flex-wrap: wrap;">
<input type="text" id="adminSearchQuery" class="form-input" placeholder="Szukaj we wszystkich postach..." style="flex: 1; min-width: 200px;">
<label style="display: flex; align-items: center; gap: var(--spacing-xs); font-size: var(--font-size-sm);">
<input type="checkbox" id="includeDeleted"> Uwzględnij usunięte
</label>
<button class="btn btn-primary" onclick="adminSearch()">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
Szukaj
</button>
</div>
<div id="searchResults" style="display: none; margin-top: var(--spacing-lg);">
<h3 style="font-size: var(--font-size-base); margin-bottom: var(--spacing-md);">Wyniki wyszukiwania (<span id="resultCount">0</span>)</h3>
<div id="searchResultsList" style="max-height: 400px; overflow-y: auto;"></div>
</div>
</div>
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
@ -512,6 +535,9 @@
<option value="resolved">Rozwiązany</option>
<option value="rejected">Odrzucony</option>
</select>
<button class="bulk-btn" onclick="openMergeModal()" title="Połącz zaznaczone tematy">
🔗 Połącz
</button>
<button class="bulk-btn danger" onclick="bulkAction('delete')" title="Usuń zaznaczone">
🗑️ Usuń
</button>
@ -579,6 +605,20 @@
<path d="M7 11V7a5 5 0 0110 0v4"></path>
</svg>
</button>
<button class="btn-icon"
onclick="openMoveModal({{ topic.id }}, '{{ topic.title|e }}', '{{ topic.category or 'question' }}')"
title="Przenies do innej kategorii">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
</button>
<button class="btn-icon"
onclick="showUserActivity({{ topic.author_id }})"
title="Aktywnosc autora">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</button>
<button class="btn-icon danger"
onclick="deleteTopic({{ topic.id }}, '{{ topic.title|e }}')"
title="Usun temat">
@ -674,6 +714,86 @@
</div>
</div>
<!-- Move Topic Modal -->
<div class="modal-overlay" id="moveTopicModal">
<div class="modal-content">
<div class="modal-header">Przenies temat</div>
<div class="modal-body">
<p id="moveModalTopicTitle" style="margin-bottom: var(--spacing-md); color: var(--text-secondary);"></p>
<div class="form-group">
<label class="form-label">Nowa kategoria</label>
<select class="form-select" id="newCategorySelect">
<option value="feature_request">Propozycja funkcji</option>
<option value="bug">Blad</option>
<option value="question">Pytanie</option>
<option value="announcement">Ogloszenie</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeMoveModal()">Anuluj</button>
<button class="btn btn-primary" onclick="saveMoveCategory()">Przenies</button>
</div>
</div>
</div>
<!-- Merge Topics Modal -->
<div class="modal-overlay" id="mergeTopicsModal">
<div class="modal-content">
<div class="modal-header">Polacz tematy</div>
<div class="modal-body">
<p style="margin-bottom: var(--spacing-md); color: var(--text-secondary);">
Wybierz temat docelowy. Pozostale tematy zostana do niego przeniesione.
</p>
<div class="form-group">
<label class="form-label">Temat docelowy</label>
<select class="form-select" id="targetTopicSelect"></select>
</div>
<div id="mergeInfo" style="font-size: var(--font-size-sm); color: var(--text-muted); margin-top: var(--spacing-md);"></div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeMergeModal()">Anuluj</button>
<button class="btn btn-primary" onclick="executeMerge()">Polacz</button>
</div>
</div>
</div>
<!-- User Activity Modal -->
<div class="modal-overlay" id="userActivityModal">
<div class="modal-content" style="max-width: 600px;">
<div class="modal-header">
Aktywnosc uzytkownika: <span id="activityUserName"></span>
</div>
<div class="modal-body">
<div id="activityStats" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--spacing-md); margin-bottom: var(--spacing-lg);">
<div style="text-align: center;">
<div style="font-size: var(--font-size-2xl); font-weight: bold; color: var(--primary);" id="statTopics">0</div>
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">Tematy</div>
</div>
<div style="text-align: center;">
<div style="font-size: var(--font-size-2xl); font-weight: bold; color: var(--success);" id="statReplies">0</div>
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">Odpowiedzi</div>
</div>
<div style="text-align: center;">
<div style="font-size: var(--font-size-2xl); font-weight: bold; color: var(--warning);" id="statSolutions">0</div>
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">Rozwiazania</div>
</div>
<div style="text-align: center;">
<div style="font-size: var(--font-size-2xl); font-weight: bold; color: var(--info);" id="statTotal">0</div>
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">Lacznie</div>
</div>
</div>
<h4 style="margin-bottom: var(--spacing-sm);">Historia aktywnosci</h4>
<div id="activityLog" style="max-height: 300px; overflow-y: auto;">
<p class="text-muted">Ladowanie...</p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeActivityModal()">Zamknij</button>
</div>
</div>
</div>
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
<style>
@ -683,6 +803,56 @@
.toast.warning { border-left-color: var(--warning); }
@keyframes toastIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
/* Search results */
.search-result-item {
padding: var(--spacing-md);
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing-md);
}
.search-result-item:hover { background: var(--background); }
.search-result-item.deleted { opacity: 0.6; }
.search-result-item .result-type {
font-size: var(--font-size-xs);
padding: 2px 6px;
border-radius: var(--radius-sm);
background: var(--background);
}
.search-result-item .result-type.topic { background: #dbeafe; color: #1e40af; }
.search-result-item .result-type.reply { background: #dcfce7; color: #166534; }
.search-result-item .result-content { flex: 1; }
.search-result-item .result-title { font-weight: 500; color: var(--text-primary); text-decoration: none; }
.search-result-item .result-title:hover { color: var(--primary); }
.search-result-item .result-preview { font-size: var(--font-size-sm); color: var(--text-secondary); margin-top: var(--spacing-xs); }
.search-result-item .result-meta { font-size: var(--font-size-xs); color: var(--text-muted); margin-top: var(--spacing-xs); }
/* Activity log */
.activity-item {
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.activity-item:last-child { border-bottom: none; }
.activity-item .activity-icon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.activity-item .activity-icon.topic { background: #dbeafe; }
.activity-item .activity-icon.reply { background: #dcfce7; }
.activity-item .activity-icon.solution { background: #fef3c7; }
.activity-item a { color: var(--text-primary); text-decoration: none; }
.activity-item a:hover { color: var(--primary); }
.activity-item .activity-date { font-size: var(--font-size-xs); color: var(--text-muted); margin-left: auto; }
</style>
{% endblock %}
@ -1001,4 +1171,270 @@
showToast('Błąd połączenia', 'error');
}
}
// ============================================================
// ADMIN SEARCH
// ============================================================
async function adminSearch() {
const query = document.getElementById('adminSearchQuery').value.trim();
const includeDeleted = document.getElementById('includeDeleted').checked;
if (query.length < 2) {
showToast('Wpisz co najmniej 2 znaki', 'warning');
return;
}
try {
const url = `/admin/forum/search?q=${encodeURIComponent(query)}${includeDeleted ? '&deleted=1' : ''}`;
const response = await fetch(url, {
headers: { 'X-CSRFToken': csrfToken }
});
const data = await response.json();
if (data.success) {
displaySearchResults(data.results, data.count);
} else {
showToast(data.error || 'Błąd wyszukiwania', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
function displaySearchResults(results, count) {
const container = document.getElementById('searchResults');
const list = document.getElementById('searchResultsList');
const countSpan = document.getElementById('resultCount');
countSpan.textContent = count;
if (results.length === 0) {
list.innerHTML = '<p class="text-muted" style="padding: var(--spacing-lg);">Brak wyników</p>';
} else {
list.innerHTML = results.map(r => `
<div class="search-result-item ${r.is_deleted ? 'deleted' : ''}">
<span class="result-type ${r.type}">${r.type === 'topic' ? 'Temat' : 'Odpowiedź'}</span>
<div class="result-content">
<a href="${r.url}" class="result-title">${r.title}</a>
${r.is_deleted ? '<span style="color: var(--error); font-size: var(--font-size-xs);"> (usunięty)</span>' : ''}
<div class="result-preview">${r.content_preview}</div>
<div class="result-meta">
${r.author_name} &bull; ${new Date(r.created_at).toLocaleString('pl-PL')}
${r.category_label ? ` &bull; ${r.category_label}` : ''}
</div>
</div>
</div>
`).join('');
}
container.style.display = 'block';
}
// Search on Enter key
document.getElementById('adminSearchQuery').addEventListener('keypress', (e) => {
if (e.key === 'Enter') adminSearch();
});
// ============================================================
// MOVE TOPIC
// ============================================================
let currentMoveTopicId = null;
function openMoveModal(topicId, title, currentCategory) {
currentMoveTopicId = topicId;
document.getElementById('moveModalTopicTitle').textContent = title;
document.getElementById('newCategorySelect').value = currentCategory;
document.getElementById('moveTopicModal').classList.add('active');
}
function closeMoveModal() {
document.getElementById('moveTopicModal').classList.remove('active');
currentMoveTopicId = null;
}
async function saveMoveCategory() {
if (!currentMoveTopicId) return;
const newCategory = document.getElementById('newCategorySelect').value;
try {
const response = await fetch(`/admin/forum/topic/${currentMoveTopicId}/move`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ category: newCategory })
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
closeMoveModal();
}
document.getElementById('moveTopicModal').addEventListener('click', (e) => {
if (e.target.id === 'moveTopicModal') closeMoveModal();
});
// ============================================================
// MERGE TOPICS
// ============================================================
function openMergeModal() {
const topicIds = getSelectedTopicIds();
if (topicIds.length < 2) {
showToast('Zaznacz co najmniej 2 tematy do połączenia', 'warning');
return;
}
const select = document.getElementById('targetTopicSelect');
select.innerHTML = '';
// Populate select with selected topics
topicIds.forEach(id => {
const row = document.querySelector(`tr[data-topic-id="${id}"]`);
const title = row ? row.querySelector('.topic-title a').textContent : `Temat #${id}`;
const option = document.createElement('option');
option.value = id;
option.textContent = title;
select.appendChild(option);
});
document.getElementById('mergeInfo').textContent = `${topicIds.length} tematów zaznaczonych. Wybierz temat docelowy - pozostałe zostaną do niego przeniesione.`;
document.getElementById('mergeTopicsModal').classList.add('active');
}
function closeMergeModal() {
document.getElementById('mergeTopicsModal').classList.remove('active');
}
async function executeMerge() {
const topicIds = getSelectedTopicIds();
const targetId = parseInt(document.getElementById('targetTopicSelect').value);
const sourceIds = topicIds.filter(id => id !== targetId);
if (sourceIds.length === 0) {
showToast('Nie można połączyć - brak tematów źródłowych', 'warning');
return;
}
const confirmed = await showConfirm(`Połączyć ${sourceIds.length} tematów z wybranym tematem docelowym?<br><br><small>Ta operacja przeniesie wszystkie odpowiedzi i usunie tematy źródłowe.</small>`, {
icon: '🔗',
title: 'Łączenie tematów',
okText: 'Połącz'
});
if (!confirmed) return;
try {
const response = await fetch('/admin/forum/merge-topics', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
target_id: targetId,
source_ids: sourceIds
})
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
closeMergeModal();
}
document.getElementById('mergeTopicsModal').addEventListener('click', (e) => {
if (e.target.id === 'mergeTopicsModal') closeMergeModal();
});
// ============================================================
// USER ACTIVITY
// ============================================================
async function showUserActivity(userId) {
document.getElementById('userActivityModal').classList.add('active');
document.getElementById('activityLog').innerHTML = '<p class="text-muted">Ładowanie...</p>';
try {
const response = await fetch(`/admin/forum/user/${userId}/activity`, {
headers: { 'X-CSRFToken': csrfToken }
});
const data = await response.json();
if (data.success) {
displayUserActivity(data);
} else {
showToast(data.error || 'Błąd ładowania aktywności', 'error');
closeActivityModal();
}
} catch (error) {
showToast('Błąd połączenia', 'error');
closeActivityModal();
}
}
function displayUserActivity(data) {
document.getElementById('activityUserName').textContent = data.user.name;
document.getElementById('statTopics').textContent = data.stats.topics;
document.getElementById('statReplies').textContent = data.stats.replies;
document.getElementById('statSolutions').textContent = data.stats.solutions;
document.getElementById('statTotal').textContent = data.stats.total_posts;
const log = document.getElementById('activityLog');
if (data.activity.length === 0) {
log.innerHTML = '<p class="text-muted">Brak aktywności</p>';
} else {
log.innerHTML = data.activity.map(a => `
<div class="activity-item">
<span class="activity-icon ${a.type} ${a.is_solution ? 'solution' : ''}">
${a.type === 'topic' ? '📝' : (a.is_solution ? '✓' : '💬')}
</span>
<div>
<span>${a.action}</span>
<a href="${a.url}">${a.title}</a>
${a.is_deleted ? '<span style="color: var(--error);"> (usunięty)</span>' : ''}
</div>
<span class="activity-date">${new Date(a.created_at).toLocaleString('pl-PL')}</span>
</div>
`).join('');
}
}
function closeActivityModal() {
document.getElementById('userActivityModal').classList.remove('active');
}
document.getElementById('userActivityModal').addEventListener('click', (e) => {
if (e.target.id === 'userActivityModal') closeActivityModal();
});
// Close all modals on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeMoveModal();
closeMergeModal();
closeActivityModal();
}
});
{% endblock %}

View File

@ -0,0 +1,541 @@
{% extends "base.html" %}
{% block title %}Analityka Forum - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.admin-header {
margin-bottom: var(--spacing-xl);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-md);
}
.admin-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
}
.header-actions {
display: flex;
gap: var(--spacing-md);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-2xl);
}
.stat-card {
background: var(--surface);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
text-align: center;
}
.stat-card.highlight {
border-left: 4px solid var(--primary);
}
.stat-value {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--primary);
}
.stat-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin-top: var(--spacing-xs);
}
.stat-sublabel {
color: var(--text-muted);
font-size: var(--font-size-xs);
margin-top: var(--spacing-xs);
}
.stat-card.warning .stat-value { color: var(--warning); }
.stat-card.success .stat-value { color: var(--success); }
.stat-card.info .stat-value { color: var(--info); }
.section {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.section h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-lg);
color: var(--text-primary);
border-bottom: 2px solid var(--border);
padding-bottom: var(--spacing-sm);
display: flex;
justify-content: space-between;
align-items: center;
}
.section h2 .badge {
font-size: var(--font-size-sm);
font-weight: normal;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.data-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.data-table tr:hover {
background: var(--background);
}
.topic-link {
color: var(--text-primary);
text-decoration: none;
font-weight: 500;
}
.topic-link:hover {
color: var(--primary);
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.badge-category {
border: 1px solid;
}
.badge-feature_request { background: #dbeafe; color: #1e40af; border-color: #93c5fd; }
.badge-bug { background: #fee2e2; color: #991b1b; border-color: #fca5a5; }
.badge-question { background: #dcfce7; color: #166534; border-color: #86efac; }
.badge-announcement { background: #fef3c7; color: #92400e; border-color: #fcd34d; }
.user-rank {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.rank-position {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: var(--font-size-sm);
}
.rank-1 { background: #fef3c7; color: #92400e; }
.rank-2 { background: #e5e7eb; color: #374151; }
.rank-3 { background: #fde68a; color: #78350f; }
.rank-default { background: var(--background); color: var(--text-secondary); }
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-sm);
}
.user-info {
display: flex;
flex-direction: column;
}
.user-name {
font-weight: 500;
color: var(--text-primary);
}
.user-email {
font-size: var(--font-size-xs);
color: var(--text-muted);
}
.chart-container {
position: relative;
height: 300px;
margin-top: var(--spacing-lg);
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: var(--spacing-md);
opacity: 0.5;
}
.activity-indicator {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.activity-indicator .icon {
width: 8px;
height: 8px;
border-radius: 50%;
}
.activity-indicator .icon.topics { background: var(--primary); }
.activity-indicator .icon.replies { background: var(--success); }
/* Date range picker */
.date-range {
display: flex;
align-items: center;
gap: var(--spacing-sm);
background: var(--background);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
font-size: var(--font-size-sm);
}
.date-range input {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-sm);
}
/* Stats comparison */
.stat-compare {
font-size: var(--font-size-xs);
margin-top: var(--spacing-xs);
}
.stat-compare.up { color: var(--success); }
.stat-compare.down { color: var(--error); }
/* Responsive */
@media (max-width: 768px) {
.admin-header {
flex-direction: column;
align-items: flex-start;
}
.header-actions {
width: 100%;
flex-wrap: wrap;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.data-table {
font-size: var(--font-size-sm);
}
}
</style>
{% endblock %}
{% block content %}
<div class="admin-header">
<div>
<h1>Analityka Forum</h1>
<p class="text-muted">Statystyki, trendy i aktywnosc uzytkownikow</p>
</div>
<div class="header-actions">
<a href="{{ url_for('admin_forum') }}" class="btn btn-outline">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
Moderacja
</a>
<a href="{{ url_for('admin_forum_export_activity') }}?start_date={{ start_date }}&end_date={{ end_date }}" class="btn btn-primary">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
Eksportuj CSV
</a>
</div>
</div>
<!-- Date Range Filter -->
<div class="section" style="padding: var(--spacing-md) var(--spacing-lg);">
<form method="GET" style="display: flex; align-items: center; gap: var(--spacing-lg); flex-wrap: wrap;">
<div class="date-range">
<label>Od:</label>
<input type="date" name="start_date" value="{{ start_date }}" onchange="this.form.submit()">
</div>
<div class="date-range">
<label>Do:</label>
<input type="date" name="end_date" value="{{ end_date }}" onchange="this.form.submit()">
</div>
<div style="display: flex; gap: var(--spacing-sm);">
<button type="button" class="btn btn-sm btn-outline" onclick="setDateRange(7)">7 dni</button>
<button type="button" class="btn btn-sm btn-outline" onclick="setDateRange(30)">30 dni</button>
<button type="button" class="btn btn-sm btn-outline" onclick="setDateRange(90)">90 dni</button>
</div>
</form>
</div>
<!-- Overview Stats -->
<div class="stats-grid">
<div class="stat-card highlight">
<div class="stat-value">{{ stats.total_topics }}</div>
<div class="stat-label">Tematow lacznie</div>
<div class="stat-sublabel">+{{ stats.topics_this_month }} w tym miesiacu</div>
</div>
<div class="stat-card highlight">
<div class="stat-value">{{ stats.total_replies }}</div>
<div class="stat-label">Odpowiedzi lacznie</div>
<div class="stat-sublabel">+{{ stats.replies_this_month }} w tym miesiacu</div>
</div>
<div class="stat-card info">
<div class="stat-value">{{ stats.active_users_7d }}</div>
<div class="stat-label">Aktywni (7 dni)</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.avg_response_time }}</div>
<div class="stat-label">Sredni czas odpowiedzi</div>
<div class="stat-sublabel">do pierwszej odpowiedzi</div>
</div>
<div class="stat-card {% if stats.unanswered_topics > 0 %}warning{% else %}success{% endif %}">
<div class="stat-value">{{ stats.unanswered_topics }}</div>
<div class="stat-label">Bez odpowiedzi</div>
</div>
<div class="stat-card success">
<div class="stat-value">{{ stats.resolved_topics }}</div>
<div class="stat-label">Rozwiazanych</div>
</div>
</div>
<!-- Activity Chart -->
<div class="section">
<h2>
Aktywnosc w czasie
<div style="display: flex; gap: var(--spacing-md);">
<span class="activity-indicator"><span class="icon topics"></span> Tematy</span>
<span class="activity-indicator"><span class="icon replies"></span> Odpowiedzi</span>
</div>
</h2>
<div class="chart-container">
<canvas id="activityChart"></canvas>
</div>
</div>
<!-- Topics Without Replies -->
<div class="section">
<h2>
Tematy bez odpowiedzi
<span class="badge {% if unanswered_topics|length > 0 %}badge-bug{% else %}badge-question{% endif %}">{{ unanswered_topics|length }}</span>
</h2>
{% if unanswered_topics %}
<table class="data-table">
<thead>
<tr>
<th>Tytul</th>
<th>Kategoria</th>
<th>Autor</th>
<th>Data</th>
<th>Oczekuje</th>
</tr>
</thead>
<tbody>
{% for topic in unanswered_topics %}
<tr>
<td>
<a href="{{ url_for('forum_topic', topic_id=topic.id) }}" class="topic-link">
{{ topic.title }}
</a>
</td>
<td>
<span class="badge badge-category badge-{{ topic.category or 'question' }}">
{{ category_labels.get(topic.category, 'Pytanie') }}
</span>
</td>
<td>{{ topic.author.name or topic.author.email.split('@')[0] }}</td>
<td>{{ topic.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
<td>{{ topic.days_waiting }} dni</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<p>Wszystkie tematy maja odpowiedzi!</p>
</div>
{% endif %}
</div>
<!-- User Rankings -->
<div class="section">
<h2>Ranking uzytkownikow</h2>
{% if user_rankings %}
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Uzytkownik</th>
<th>Tematy</th>
<th>Odpowiedzi</th>
<th>Rozwiazania</th>
<th>Reakcje</th>
<th>Suma</th>
</tr>
</thead>
<tbody>
{% for user in user_rankings %}
<tr>
<td>
<span class="rank-position {% if loop.index <= 3 %}rank-{{ loop.index }}{% else %}rank-default{% endif %}">
{{ loop.index }}
</span>
</td>
<td>
<div class="user-rank">
<div class="user-avatar">{{ (user.name or user.email)[0]|upper }}</div>
<div class="user-info">
<span class="user-name">{{ user.name or user.email.split('@')[0] }}</span>
<span class="user-email">{{ user.email }}</span>
</div>
</div>
</td>
<td>{{ user.topic_count }}</td>
<td>{{ user.reply_count }}</td>
<td>{{ user.solution_count }}</td>
<td>{{ user.reaction_count }}</td>
<td><strong>{{ user.total_score }}</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
<p>Brak danych o aktywnosci uzytkownikow</p>
</div>
{% endif %}
</div>
<!-- Category Stats -->
<div class="section">
<h2>Statystyki kategorii</h2>
<div class="stats-grid" style="margin-bottom: 0;">
{% for cat, count in category_stats.items() %}
<div class="stat-card">
<div class="stat-value" style="font-size: var(--font-size-2xl);">{{ count }}</div>
<div class="stat-label">
<span class="badge badge-category badge-{{ cat }}">{{ category_labels.get(cat, cat) }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
{% endblock %}
{% block extra_js %}
const chartData = {{ chart_data|tojson|safe }};
// Activity Chart
const ctx = document.getElementById('activityChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: chartData.labels,
datasets: [
{
label: 'Tematy',
data: chartData.topics,
borderColor: 'rgb(79, 70, 229)',
backgroundColor: 'rgba(79, 70, 229, 0.1)',
fill: true,
tension: 0.4
},
{
label: 'Odpowiedzi',
data: chartData.replies,
borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
fill: true,
tension: 0.4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'index',
intersect: false
}
},
scales: {
x: {
grid: {
display: false
}
},
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}
});
// Date range helpers
function setDateRange(days) {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - days);
document.querySelector('input[name="start_date"]').value = formatDate(start);
document.querySelector('input[name="end_date"]').value = formatDate(end);
document.querySelector('form').submit();
}
function formatDate(date) {
return date.toISOString().split('T')[0];
}
{% endblock %}