nordabiz/templates/admin/social_publisher.html
Maciej Pienczyn c42643b07c
Some checks are pending
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
fix: 0 is falsy in JS - \"Wszystkie\" dropdown now works correctly
perPage=0 (show all) was being overridden to 10 by || operator.
Changed to explicit key-in-object check to preserve 0 value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 09:39:12 +01:00

1418 lines
64 KiB
HTML

{% extends "base.html" %}
{% block title %}Social Media Dashboard - 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-sm);
align-items: center;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.stat-card {
background: var(--surface);
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
text-align: center;
}
.stat-card.total { border-top: 3px solid var(--primary); }
.stat-card.draft { border-top: 3px solid var(--text-secondary); }
.stat-card.approved { border-top: 3px solid var(--info, #0ea5e9); }
.stat-card.scheduled { border-top: 3px solid var(--warning); }
.stat-card.published { border-top: 3px solid var(--success); }
.stat-card.failed { border-top: 3px solid var(--error); }
.stat-value {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--primary);
}
.stat-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.filters-row {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
align-items: center;
}
.filter-group {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.filter-group label {
font-weight: 500;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.filter-group select {
padding: var(--spacing-xs) var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
font-size: var(--font-size-sm);
}
.section {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.posts-table {
width: 100%;
border-collapse: collapse;
}
.posts-table th,
.posts-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.posts-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
text-transform: uppercase;
background: var(--background);
}
.posts-table tr:hover {
background: var(--background);
}
.content-preview {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
}
.status-badge {
display: inline-block;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: 600;
}
.status-badge.draft { background: var(--surface-secondary, #f1f5f9); color: var(--text-secondary); }
.status-badge.approved { background: #e0f2fe; color: #0369a1; }
.status-badge.scheduled { background: var(--warning-bg); color: var(--warning); }
.status-badge.published { background: var(--success-bg); color: var(--success); }
.status-badge.failed { background: var(--error-bg); color: var(--error); }
.type-badge {
display: inline-block;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
background: var(--primary-bg);
color: var(--primary);
}
.engagement-cell {
font-size: var(--font-size-sm);
color: var(--text-secondary);
white-space: nowrap;
}
.engagement-cell span {
margin-right: var(--spacing-xs);
}
.btn-small {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-xs);
}
.actions-cell {
white-space: nowrap;
display: flex;
gap: var(--spacing-xs);
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
/* Facebook Page Posts cards */
.fb-posts-section {
margin-bottom: var(--spacing-lg);
}
.fb-posts-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-md);
}
.fb-posts-header h3 {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
}
.fb-post-card {
display: flex;
gap: var(--spacing-md);
padding: var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: var(--spacing-sm);
background: var(--surface);
transition: box-shadow 0.2s;
}
.fb-post-card:hover {
box-shadow: var(--shadow-sm);
}
.fb-post-thumb {
flex-shrink: 0;
width: 120px;
height: 90px;
border-radius: var(--radius-sm);
object-fit: cover;
background: var(--background);
}
.fb-post-body {
flex: 1;
min-width: 0;
}
.fb-post-date {
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-bottom: 4px;
}
.fb-post-text {
font-size: var(--font-size-sm);
color: var(--text-primary);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
margin-bottom: var(--spacing-xs);
}
.fb-post-metrics {
display: flex;
gap: var(--spacing-md);
flex-wrap: wrap;
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.fb-post-metrics span {
display: inline-flex;
align-items: center;
gap: 3px;
}
.fb-post-actions {
display: flex;
gap: var(--spacing-xs);
margin-top: var(--spacing-xs);
align-items: center;
}
.fb-post-actions a,
.fb-post-actions button {
font-size: var(--font-size-xs);
}
.fb-post-insights {
display: flex;
gap: var(--spacing-md);
flex-wrap: wrap;
margin-top: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--background);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.fb-post-insights span {
display: inline-flex;
align-items: center;
gap: 3px;
}
/* Facebook Charts */
.fb-charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: var(--spacing-lg);
margin-top: var(--spacing-lg);
}
.fb-chart-card {
background: var(--surface);
padding: var(--spacing-md);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.fb-chart-card h4 {
font-size: var(--font-size-md);
margin-bottom: var(--spacing-sm);
}
@media (max-width: 768px) {
.fb-post-card {
flex-direction: column;
}
.fb-post-thumb {
width: 100%;
height: 160px;
}
.fb-charts-grid {
grid-template-columns: 1fr;
}
.posts-table {
font-size: var(--font-size-sm);
}
.posts-table th:nth-child(3),
.posts-table td:nth-child(3),
.posts-table th:nth-child(4),
.posts-table td:nth-child(4),
.posts-table th:nth-child(6),
.posts-table td:nth-child(6),
.posts-table th:nth-child(7),
.posts-table td:nth-child(7) {
display: none;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="admin-header">
<h1>Social Media Dashboard</h1>
<div class="header-actions">
<a href="{{ url_for('admin.social_publisher_settings') }}" class="btn btn-secondary">Ustawienia</a>
<a href="{{ url_for('admin.social_publisher_new') }}" class="btn btn-primary">+ Nowy Post</a>
</div>
</div>
<!-- Statystyki -->
<div class="stats-grid">
<div class="stat-card total">
<div class="stat-value">{{ stats.total or 0 }}</div>
<div class="stat-label">Wszystkie</div>
</div>
<div class="stat-card draft">
<div class="stat-value">{{ stats.draft or 0 }}</div>
<div class="stat-label">Szkice</div>
</div>
<div class="stat-card approved">
<div class="stat-value">{{ stats.approved or 0 }}</div>
<div class="stat-label">Zatwierdzone</div>
</div>
<div class="stat-card scheduled">
<div class="stat-value">{{ stats.scheduled or 0 }}</div>
<div class="stat-label">Zaplanowane</div>
</div>
<div class="stat-card published">
<div class="stat-value">{{ stats.published or 0 }}</div>
<div class="stat-label">Opublikowane</div>
</div>
<div class="stat-card failed">
<div class="stat-value">{{ stats.failed or 0 }}</div>
<div class="stat-label">Bledy</div>
</div>
</div>
<!-- Facebook Page Stats (from API) -->
{% if fb_stats %}
{% for company_id, fb in fb_stats.items() %}
<div style="background: linear-gradient(135deg, #1877f2 0%, #0d5bbf 100%); border-radius: var(--radius-lg); padding: var(--spacing-lg); margin-bottom: var(--spacing-lg); color: white;">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--spacing-md);">
<div style="display: flex; align-items: center; gap: var(--spacing-md);">
<svg width="28" height="28" fill="white" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
<div>
<div style="font-weight: 700; font-size: var(--font-size-lg);">{{ fb.page_name or 'Strona Facebook' }}</div>
{% if fb.content_types and fb.content_types.get('category') %}
<div style="font-size: var(--font-size-xs); opacity: 0.85;">{{ fb.content_types['category'] }}</div>
{% endif %}
</div>
</div>
<div style="display: flex; gap: var(--spacing-xl); align-items: center; flex-wrap: wrap;">
{% if fb.followers_count %}
<div style="text-align: center;">
<div style="font-size: var(--font-size-2xl); font-weight: 700;">{{ '{:,}'.format(fb.followers_count).replace(',', ' ') }}</div>
<div style="font-size: var(--font-size-xs); opacity: 0.8;">Obserwujących</div>
</div>
{% endif %}
{% if fb.engagement_rate is not none %}
<div style="text-align: center;">
<div style="font-size: var(--font-size-2xl); font-weight: 700;">{{ '%.1f'|format(fb.engagement_rate) }}%</div>
<div style="font-size: var(--font-size-xs); opacity: 0.8;">Engagement</div>
</div>
{% endif %}
{% if fb.profile_completeness_score is not none %}
<div style="text-align: center;">
<div style="font-size: var(--font-size-2xl); font-weight: 700;">{{ fb.profile_completeness_score }}%</div>
<div style="font-size: var(--font-size-xs); opacity: 0.8;">Profil</div>
</div>
{% endif %}
<button onclick="syncFacebookData({{ company_id }}, this)" style="background: rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.4); color: white; padding: 6px 14px; border-radius: var(--radius); cursor: pointer; font-size: var(--font-size-xs); transition: background 0.2s;" onmouseover="this.style.background='rgba(255,255,255,0.3)'" onmouseout="this.style.background='rgba(255,255,255,0.2)'">
<svg width="14" height="14" fill="currentColor" viewBox="0 0 20 20" style="vertical-align: -2px; margin-right: 4px;"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg>
Odśwież
</button>
</div>
</div>
{% if fb.content_types %}
{% set extras = fb.content_types %}
{% if extras.get('phone') or extras.get('website') or extras.get('address') %}
<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: var(--spacing-md); padding-top: var(--spacing-md); border-top: 1px solid rgba(255,255,255,0.2);">
{% if extras.get('phone') %}
<span style="display: inline-flex; align-items: center; gap: 4px; font-size: 12px; background: rgba(255,255,255,0.15); padding: 3px 10px; border-radius: 12px;">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg>
{{ extras['phone'] }}
</span>
{% endif %}
{% if extras.get('website') %}
<a href="{{ extras['website'] }}" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 4px; font-size: 12px; background: rgba(255,255,255,0.15); padding: 3px 10px; border-radius: 12px; color: white; text-decoration: none;">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/></svg>
{{ extras['website']|replace('https://', '')|replace('http://', '')|truncate(30) }}
</a>
{% endif %}
{% if extras.get('address') %}
<span style="display: inline-flex; align-items: center; gap: 4px; font-size: 12px; background: rgba(255,255,255,0.15); padding: 3px 10px; border-radius: 12px;">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17.657 16.657L13.414 20.9a2 2 0 01-2.828 0l-4.243-4.243a8 8 0 1111.314 0z"/><path d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
{{ extras['address']|truncate(35) }}
</span>
{% endif %}
</div>
{% endif %}
{% endif %}
{% if fb.last_checked_at %}
<div style="margin-top: var(--spacing-sm); font-size: 11px; opacity: 0.6;">Ostatnia synchronizacja: {{ fb.last_checked_at.strftime('%d.%m.%Y %H:%M') }}</div>
{% endif %}
<div style="margin-top: var(--spacing-sm); font-size: 11px; opacity: 0.5;">
Publikowanie nie działa? Zmiana hasła FB lub usunięcie aplikacji wymaga ponownego połączenia w
<a href="{{ url_for('auth.konto_integracje') }}" style="color: white; text-decoration: underline;">Integracje</a>.
</div>
</div>
{% endfor %}
<!-- Ostatnie posty z Facebook -->
{% for company_id_key, fb in fb_stats.items() %}
<div class="fb-posts-section" id="fbPostsSection-{{ company_id_key }}">
<div class="fb-posts-header">
<h3>Posty na Facebook
{% if cached_fb_posts.get(company_id_key) %}
<span style="font-size: var(--font-size-xs); color: var(--text-secondary); font-weight: 400;">
({{ cached_fb_posts[company_id_key].total_count }} postów, cache z {{ cached_fb_posts[company_id_key].cached_at.strftime('%d.%m %H:%M') }})
</span>
{% endif %}
</h3>
<div style="display: flex; gap: var(--spacing-xs); align-items: center; flex-wrap: wrap;">
<button class="btn btn-secondary btn-small" onclick="loadFbPosts({{ company_id_key }}, this)">Najnowsze 10</button>
<button class="btn btn-primary btn-small" onclick="refreshAllFbPosts({{ company_id_key }}, this)">Odśwież wszystkie</button>
</div>
</div>
<div id="fbChartsSection-{{ company_id_key }}" style="display:none;">
<div id="fbChartsInfo-{{ company_id_key }}" style="text-align:center;margin-bottom:var(--spacing-sm);padding:var(--spacing-xs) var(--spacing-sm);color:var(--text-secondary);font-size:var(--font-size-sm);background:var(--surface);border-radius:var(--radius);border:1px solid var(--border);"></div>
<div class="fb-charts-grid">
<div class="fb-chart-card"><h4>Engagement w czasie</h4><canvas id="engagementChart-{{ company_id_key }}"></canvas></div>
<div class="fb-chart-card"><h4>Aktywność publikacji</h4><canvas id="activityChart-{{ company_id_key }}"></canvas></div>
<div class="fb-chart-card"><h4>Średni engagement / miesiąc</h4><canvas id="avgEngagementChart-{{ company_id_key }}"></canvas></div>
<div class="fb-chart-card"><h4>Typy postów</h4><div style="height:200px;"><canvas id="postTypesChart-{{ company_id_key }}"></canvas></div></div>
<div class="fb-chart-card"><h4>Najlepszy dzień tygodnia</h4><canvas id="bestDayChart-{{ company_id_key }}"></canvas></div>
<div class="fb-chart-card"><h4>Najlepsza godzina</h4><canvas id="bestHourChart-{{ company_id_key }}"></canvas></div>
<div class="fb-chart-card" style="grid-column: 1 / -1;"><h4>Top 5 postów</h4><div style="height:200px;"><canvas id="topPostsChart-{{ company_id_key }}"></canvas></div></div>
</div>
</div>
<div id="fbPostsContainer-{{ company_id_key }}"></div>
</div>
{% endfor %}
{% endif %}
<!-- Filtry -->
<div class="filters-row">
<div class="filter-group">
<label for="status-filter">Status:</label>
<select id="status-filter" onchange="applyFilters()">
<option value="all" {% if status_filter == 'all' %}selected{% endif %}>Wszystkie</option>
<option value="draft" {% if status_filter == 'draft' %}selected{% endif %}>Szkic</option>
<option value="approved" {% if status_filter == 'approved' %}selected{% endif %}>Zatwierdzony</option>
<option value="scheduled" {% if status_filter == 'scheduled' %}selected{% endif %}>Zaplanowany</option>
<option value="published" {% if status_filter == 'published' %}selected{% endif %}>Opublikowany</option>
<option value="failed" {% if status_filter == 'failed' %}selected{% endif %}>Błąd</option>
</select>
</div>
<div class="filter-group">
<label for="type-filter">Typ:</label>
<select id="type-filter" onchange="applyFilters()">
<option value="all" {% if type_filter == 'all' %}selected{% endif %}>Wszystkie</option>
{% for key, label in post_types.items() %}
<option value="{{ key }}" {% if type_filter == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
{% if configured_companies %}
<div class="filter-group">
<label for="company-filter">Firma:</label>
<select id="company-filter" onchange="applyFilters()">
<option value="all" {% if company_filter == 'all' %}selected{% endif %}>Wszystkie</option>
{% for cc in configured_companies %}
<option value="{{ cc.company_id }}" {% if company_filter == cc.company_id|string %}selected{% endif %}>{{ cc.company_name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
</div>
<!-- Tabela postów -->
<div class="section">
{% if posts %}
<table class="posts-table">
<thead>
<tr>
<th>Typ</th>
<th>Tresc</th>
<th>Firma</th>
<th>Publikuje jako</th>
<th>Status</th>
<th>Data</th>
<th>Engagement</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for post in posts %}
<tr>
<td>
<span class="type-badge">{{ post_types.get(post.post_type, post.post_type) }}</span>
</td>
<td class="content-preview">
<a href="{{ url_for('admin.social_publisher_edit', post_id=post.id) }}" style="color: var(--text-primary); text-decoration: none;">
{{ post.content[:80] }}{% if post.content|length > 80 %}...{% endif %}
</a>
</td>
<td>{{ post.company.name if post.company else '-' }}</td>
<td>{{ post.publishing_company.name if post.publishing_company else '-' }}</td>
<td>
<span class="status-badge {{ post.status }}">
{% if post.status == 'draft' %}Szkic
{% elif post.status == 'approved' %}Zatwierdzony
{% elif post.status == 'scheduled' %}Zaplanowany
{% elif post.status == 'published' %}Opublikowany
{% elif post.status == 'failed' %}Błąd
{% else %}{{ post.status }}{% endif %}
</span>
</td>
<td style="white-space: nowrap; font-size: var(--font-size-sm); color: var(--text-secondary);">
{% if post.published_at %}
{{ post.published_at.strftime('%Y-%m-%d %H:%M') }}
{% elif post.scheduled_at %}
{{ post.scheduled_at.strftime('%Y-%m-%d %H:%M') }}
{% else %}
{{ post.created_at.strftime('%Y-%m-%d %H:%M') if post.created_at else '-' }}
{% endif %}
</td>
<td class="engagement-cell">
{% if post.status == 'published' and (post.engagement_likes or post.engagement_comments or post.engagement_shares) %}
<span title="Polubienia">&#128077; {{ post.engagement_likes or 0 }}</span>
<span title="Komentarze">&#128172; {{ post.engagement_comments or 0 }}</span>
<span title="Udostepnienia">&#128257; {{ post.engagement_shares or 0 }}</span>
{% else %}
-
{% endif %}
</td>
<td class="actions-cell">
<a href="{{ url_for('admin.social_publisher_edit', post_id=post.id) }}" class="btn btn-secondary btn-small">
Edytuj
</a>
{% if post.status == 'draft' %}
<form method="POST" action="{{ url_for('admin.social_publisher_approve', post_id=post.id) }}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-info btn-small" style="background: #0ea5e9; color: white; border: none;">Zatwierdz</button>
</form>
{% endif %}
{% if post.status in ['draft', 'approved'] %}
<button class="btn btn-success btn-small" onclick="publishPost({{ post.id }})">Publikuj</button>
{% endif %}
{% if post.status != 'published' %}
<button class="btn btn-error btn-small" onclick="deletePost({{ post.id }})">Usuń</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>Brak postów{% if status_filter != 'all' or type_filter != 'all' %} pasujących do filtrów{% endif %}.</p>
<p style="margin-top: var(--spacing-md);">
<a href="{{ url_for('admin.social_publisher_new') }}" class="btn btn-primary">Utwórz pierwszy post</a>
</p>
</div>
{% endif %}
</div>
</div>
<!-- Confirm Modal -->
<div class="modal-overlay" id="confirmModal">
<div class="modal" style="max-width: 420px; background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl);">
<div style="text-align: center; margin-bottom: var(--spacing-lg);">
<div class="modal-icon" id="confirmModalIcon" style="font-size: 3em; margin-bottom: var(--spacing-md);">&#10067;</div>
<h3 id="confirmModalTitle" style="margin-bottom: var(--spacing-sm);">Potwierdzenie</h3>
<p id="confirmModalMessage" style="color: var(--text-secondary);"></p>
</div>
<div style="display: flex; gap: var(--spacing-sm); justify-content: center;">
<button type="button" class="btn btn-secondary" id="confirmModalCancel">Anuluj</button>
<button type="button" class="btn btn-primary" id="confirmModalOk">OK</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>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
.modal-overlay#confirmModal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1050; align-items: center; justify-content: center; }
.modal-overlay#confirmModal.active { display: flex; }
.toast { padding: 12px 20px; border-radius: var(--radius); background: var(--surface); border-left: 4px solid var(--primary); box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; gap: 10px; animation: toastIn 0.3s ease; }
.toast.success { border-left-color: var(--success); }
.toast.error { border-left-color: var(--error); }
@keyframes toastIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
</style>
{% endblock %}
{% block extra_js %}
function applyFilters() {
const status = document.getElementById('status-filter').value;
const type = document.getElementById('type-filter').value;
const companyEl = document.getElementById('company-filter');
const company = companyEl ? companyEl.value : 'all';
let url = '{{ url_for("admin.social_publisher_list") }}?';
if (status !== 'all') url += 'status=' + status + '&';
if (type !== 'all') url += 'type=' + type + '&';
if (company !== 'all') url += 'company=' + company + '&';
window.location.href = url;
}
let confirmResolve = null;
function showConfirm(message, options = {}) {
return new Promise(resolve => {
confirmResolve = resolve;
document.getElementById('confirmModalIcon').innerHTML = options.icon || '&#10067;';
document.getElementById('confirmModalTitle').textContent = options.title || 'Potwierdzenie';
document.getElementById('confirmModalMessage').innerHTML = message;
document.getElementById('confirmModalOk').textContent = options.okText || 'OK';
document.getElementById('confirmModalOk').className = 'btn ' + (options.okClass || 'btn-primary');
document.getElementById('confirmModal').classList.add('active');
});
}
function closeConfirm(result) {
document.getElementById('confirmModal').classList.remove('active');
if (confirmResolve) { confirmResolve(result); confirmResolve = null; }
}
document.getElementById('confirmModalOk').addEventListener('click', () => closeConfirm(true));
document.getElementById('confirmModalCancel').addEventListener('click', () => closeConfirm(false));
document.getElementById('confirmModal').addEventListener('click', e => { if (e.target.id === 'confirmModal') closeConfirm(false); });
function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toastContainer');
const icons = { success: '&#10003;', error: '&#10007;', warning: '&#9888;', info: '&#8505;' };
const toast = document.createElement('div');
toast.className = 'toast ' + type;
toast.innerHTML = '<span style="font-size:1.2em">' + (icons[type]||'&#8505;') + '</span><span>' + message + '</span>';
container.appendChild(toast);
setTimeout(() => { toast.style.animation = 'toastOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); }, duration);
}
async function publishPost(id) {
const confirmed = await showConfirm('Czy na pewno chcesz opublikować ten post na Facebook?', {
icon: '&#128227;',
title: 'Publikacja posta',
okText: 'Publikuj',
okClass: 'btn-success'
});
if (!confirmed) return;
try {
const response = await fetch('{{ url_for("admin.social_publisher_publish", post_id=0) }}'.replace('/0/', '/' + id + '/'), {
method: 'POST',
headers: { 'X-CSRFToken': '{{ csrf_token() }}' }
});
const data = await response.json();
if (data.success) {
showToast('Post został opublikowany na Facebook', 'success');
setTimeout(() => location.reload(), 1500);
} else {
showToast('Błąd: ' + (data.error || 'Nieznany błąd'), 'error');
}
} catch (err) {
showToast('Błąd połączenia: ' + err.message, 'error');
}
}
async function deletePost(id) {
const confirmed = await showConfirm('Czy na pewno chcesz usunąć ten post? Ta operacja jest nieodwracalna.', {
icon: '&#128465;',
title: 'Usuwanie posta',
okText: 'Usuń',
okClass: 'btn-error'
});
if (!confirmed) return;
try {
const response = await fetch('{{ url_for("admin.social_publisher_delete", post_id=0) }}'.replace('/0/', '/' + id + '/'), {
method: 'POST',
headers: { 'X-CSRFToken': '{{ csrf_token() }}' }
});
const data = await response.json();
if (data.success) {
showToast('Post został usunięty', 'success');
setTimeout(() => location.reload(), 1500);
} else {
showToast('Błąd: ' + (data.error || 'Nieznany błąd'), 'error');
}
} catch (err) {
showToast('Błąd połączenia: ' + err.message, 'error');
}
}
function formatFbDate(isoStr) {
if (!isoStr) return '';
var d = new Date(isoStr);
return d.toLocaleDateString('pl-PL', {day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'});
}
// Store all posts per company for client-side pagination
window._fbAllPosts = window._fbAllPosts || {};
window._fbPostsPage = window._fbPostsPage || {};
window._fbPostsPerPage = window._fbPostsPerPage || {};
function renderFbPosts(companyId, posts, nextCursor, append) {
var container = document.getElementById('fbPostsContainer-' + companyId);
// Store posts; only reset page if new dataset is different size
var prevLen = (window._fbAllPosts[companyId] || []).length;
window._fbAllPosts[companyId] = posts;
if (posts.length !== prevLen) window._fbPostsPage[companyId] = 1;
if (!window._fbPostsPerPage[companyId]) window._fbPostsPerPage[companyId] = 10;
renderFbPostsPage(companyId);
}
function renderFbPostsPage(companyId) {
var container = document.getElementById('fbPostsContainer-' + companyId);
var allPosts = window._fbAllPosts[companyId] || [];
var perPage = (companyId in window._fbPostsPerPage) ? window._fbPostsPerPage[companyId] : 10;
var page = window._fbPostsPage[companyId] || 1;
// Calculate slice
var showAll = (perPage === 0);
var visible = showAll ? allPosts : allPosts.slice(0, page * perPage);
var hasMore = !showAll && (page * perPage < allPosts.length);
var html = '';
visible.forEach(function(post) {
html += '<div class="fb-post-card">';
if (post.full_picture) {
html += '<img class="fb-post-thumb" src="' + post.full_picture + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">';
}
html += '<div class="fb-post-body">';
html += '<div class="fb-post-date">' + formatFbDate(post.created_time);
if (post.status_type) html += ' &middot; ' + post.status_type;
html += '</div>';
if (post.message) {
html += '<div class="fb-post-text">' + post.message.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</div>';
} else {
html += '<div class="fb-post-text" style="font-style:italic;color:var(--text-secondary);">(post bez tekstu)</div>';
}
var engScore = (post.reactions_total || 0) + (post.comments || 0) * 2 + (post.shares || 0) * 3;
html += '<div class="fb-post-metrics">';
html += '<span title="Reakcje (wszystkie)">&#10084;&#65039; ' + (post.reactions_total || 0) + '</span>';
html += '<span title="Komentarze">&#128172; ' + (post.comments || 0) + '</span>';
html += '<span title="Udostępnienia">&#128257; ' + (post.shares || 0) + '</span>';
html += '<span title="Wynik engagement (reakcje + komentarze x2 + udostępnienia x3)" style="font-weight:600;color:var(--primary);">&#9889; ' + engScore + '</span>';
html += '</div>';
html += '<div class="fb-post-actions">';
if (post.permalink_url) {
html += '<a href="' + post.permalink_url + '" target="_blank" rel="noopener" class="btn btn-secondary btn-small" style="font-size:11px;">Zobacz na FB</a>';
}
html += '<button class="btn btn-secondary btn-small" style="font-size:11px;" onclick="loadPostInsights(' + companyId + ', \'' + post.id + '\', this)">Insights</button>';
html += '</div>';
html += '<div id="insights-' + post.id.replace(/\./g, '-') + '" style="display:none;"></div>';
html += '</div></div>';
});
// Pagination bar helper
function paginationBar(margin) {
var bar = '<div style="display:flex;justify-content:space-between;align-items:center;' + margin + ';padding:var(--spacing-sm);background:var(--surface);border-radius:var(--radius);border:1px solid var(--border);font-size:var(--font-size-sm);">';
bar += '<span style="color:var(--text-secondary);">Wyświetlono ' + visible.length + ' z ' + allPosts.length + ' postów</span>';
bar += '<div style="display:flex;gap:var(--spacing-xs);align-items:center;">';
bar += '<label style="color:var(--text-secondary);">Pokaż:</label>';
bar += '<select onchange="changeFbPerPage(' + companyId + ', this.value)" style="padding:4px 8px;border-radius:var(--radius);border:1px solid var(--border);font-size:var(--font-size-sm);">';
[10, 20, 50, 100, 0].forEach(function(v) {
var label = v === 0 ? 'Wszystkie' : v;
var selected = (v === perPage) ? ' selected' : '';
bar += '<option value="' + v + '"' + selected + '>' + label + '</option>';
});
bar += '</select>';
if (hasMore) {
bar += '<button class="btn btn-secondary btn-small" onclick="loadMoreFbPosts(' + companyId + ')">Pokaż więcej</button>';
}
bar += '</div></div>';
return bar;
}
container.innerHTML = paginationBar('margin-bottom:var(--spacing-md)') + html + paginationBar('margin-top:var(--spacing-md)');
}
function changeFbPerPage(companyId, value) {
window._fbPostsPerPage[companyId] = parseInt(value);
window._fbPostsPage[companyId] = 1;
renderFbPostsPage(companyId);
}
function loadMoreFbPosts(companyId) {
window._fbPostsPage[companyId] = (window._fbPostsPage[companyId] || 1) + 1;
renderFbPostsPage(companyId);
}
function loadFbPosts(companyId, btn, afterCursor) {
var origText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Ładowanie...';
var container = document.getElementById('fbPostsContainer-' + companyId);
var isAppend = !!afterCursor;
if (!isAppend) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-secondary);">Pobieranie postów z Facebook API...</div>';
}
var url = '/admin/social-publisher/fb-posts/' + companyId;
if (afterCursor) url += '?after=' + encodeURIComponent(afterCursor);
fetch(url)
.then(function(r) { return r.json(); })
.then(function(data) {
btn.textContent = origText;
btn.disabled = false;
if (!data.success) {
if (!isAppend) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--error);">' + (data.error || 'Błąd') + '</div>';
} else {
showToast(data.error || 'Błąd pobierania', 'error');
}
return;
}
if (!data.posts || data.posts.length === 0) {
if (!isAppend) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-secondary);">Brak postów na stronie.</div>';
} else {
var oldMore = document.getElementById('fbLoadMore-' + companyId);
if (oldMore) oldMore.innerHTML = '<span style="color:var(--text-secondary);font-size:var(--font-size-sm);">To już wszystkie posty.</span>';
}
return;
}
renderFbPosts(companyId, data.posts, data.next_cursor, isAppend);
})
.catch(function(err) {
btn.textContent = origText;
btn.disabled = false;
if (!isAppend) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--error);">Błąd połączenia: ' + err.message + '</div>';
} else {
showToast('Błąd połączenia: ' + err.message, 'error');
}
});
}
function loadPostInsights(companyId, postId, btn) {
var safeId = postId.replace(/\./g, '-');
var container = document.getElementById('insights-' + safeId);
if (!container) return;
if (container.style.display !== 'none') {
container.style.display = 'none';
btn.textContent = 'Insights';
return;
}
btn.disabled = true;
btn.textContent = 'Ładowanie...';
container.innerHTML = '<div class="fb-post-insights">Pobieranie statystyk...</div>';
container.style.display = 'block';
fetch('/admin/social-publisher/fb-post-insights/' + companyId + '/' + postId)
.then(function(r) { return r.json(); })
.then(function(data) {
btn.disabled = false;
btn.textContent = 'Insights';
if (!data.success) {
container.innerHTML = '<div class="fb-post-insights" style="color:var(--text-secondary);">' + (data.error || 'Brak danych') + '</div>';
return;
}
var ins = data.insights;
var html = '<div class="fb-post-insights">';
html += '<span title="Wyswietlenia">&#128065; Wyswietlenia: <strong>' + (ins.impressions != null ? ins.impressions : '-') + '</strong></span>';
html += '<span title="Zasieg">&#128202; Zasieg: <strong>' + (ins.reach != null ? ins.reach : '-') + '</strong></span>';
html += '<span title="Zaangazowani">&#128101; Zaangazowani: <strong>' + (ins.engaged_users != null ? ins.engaged_users : '-') + '</strong></span>';
html += '<span title="Klikniecia">&#128433;&#65039; Klikniecia: <strong>' + (ins.clicks != null ? ins.clicks : '-') + '</strong></span>';
html += '</div>';
container.innerHTML = html;
})
.catch(function(err) {
btn.disabled = false;
btn.textContent = 'Insights';
container.innerHTML = '<div class="fb-post-insights" style="color:var(--error);">Błąd: ' + err.message + '</div>';
});
}
// Store chart instances for cleanup
window._fbCharts = window._fbCharts || {};
async function loadAllFbPosts(companyId, btn) {
var origText = btn.textContent;
btn.disabled = true;
var container = document.getElementById('fbPostsContainer-' + companyId);
// Try DB cache first (instant)
btn.textContent = 'Ładowanie...';
try {
var cacheR = await fetch('/admin/social-publisher/fb-posts-cache/' + companyId);
var cacheData = await cacheR.json();
if (cacheData.success && cacheData.posts && cacheData.posts.length > 0) {
btn.textContent = origText;
btn.disabled = false;
renderFbPosts(companyId, cacheData.posts, null, false);
container.insertAdjacentHTML('beforeend',
'<div style="text-align:center;margin-top:var(--spacing-md);color:var(--text-secondary);font-size:var(--font-size-sm);">Wyświetlono ' + cacheData.posts.length + ' postów z cache</div>');
renderFbCharts(companyId, cacheData.posts, cacheData.cached_at);
return;
}
} catch(e) {}
// No cache — fetch all pages from FB API
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-secondary);">Pierwsze ładowanie — pobieranie postów z Facebook API...</div>';
var allPosts = [];
var seenIds = {};
var seenCursors = {};
var cursor = null;
var page = 0;
var MAX_PAGES = 100;
try {
while (page < MAX_PAGES) {
page++;
btn.textContent = 'Ładowanie... (' + allPosts.length + ' postów, strona ' + page + ')';
var url = '/admin/social-publisher/fb-posts/' + companyId;
if (cursor) url += '?after=' + encodeURIComponent(cursor);
var r = await fetch(url);
var data = await r.json();
if (!data.success) {
showToast(data.error || 'Błąd pobierania', 'error');
break;
}
if (!data.posts || data.posts.length === 0) break;
// Deduplicate by post ID
var newPosts = data.posts.filter(function(p) {
if (seenIds[p.id]) return false;
seenIds[p.id] = true;
return true;
});
if (newPosts.length === 0) break; // all duplicates = cycle
allPosts = allPosts.concat(newPosts);
if (!data.next_cursor) break;
// Detect cursor cycle
if (seenCursors[data.next_cursor]) break;
seenCursors[data.next_cursor] = true;
cursor = data.next_cursor;
}
btn.textContent = origText;
btn.disabled = false;
if (allPosts.length === 0) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-secondary);">Brak postów na stronie.</div>';
return;
}
renderFbPosts(companyId, allPosts, null, false);
container.insertAdjacentHTML('beforeend',
'<div style="text-align:center;margin-top:var(--spacing-md);color:var(--text-secondary);font-size:var(--font-size-sm);">Załadowano ' + allPosts.length + ' postów z Facebook API</div>');
renderFbCharts(companyId, allPosts, 'teraz (świeżo pobrane)');
saveFbPostsToCache(companyId, allPosts);
} catch (err) {
btn.textContent = origText;
btn.disabled = false;
showToast('Błąd połączenia: ' + err.message, 'error');
}
}
async function refreshAllFbPosts(companyId, btn) {
var origText = btn.textContent;
btn.disabled = true;
var container = document.getElementById('fbPostsContainer-' + companyId);
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-secondary);">Pobieranie WSZYSTKICH postów z Facebook API...</div>';
var allPosts = [];
var seenIds = {};
var seenCursors = {};
var cursor = null;
var page = 0;
var MAX_PAGES = 100;
try {
while (page < MAX_PAGES) {
page++;
btn.textContent = 'Pobieranie... (' + allPosts.length + ' postów, strona ' + page + ')';
var url = '/admin/social-publisher/fb-posts/' + companyId;
if (cursor) url += '?after=' + encodeURIComponent(cursor);
var response = await fetch(url);
var data = await response.json();
if (!data.success || !data.posts || data.posts.length === 0) break;
var newPosts = data.posts.filter(function(p) {
if (seenIds[p.id]) return false;
seenIds[p.id] = true;
return true;
});
if (newPosts.length === 0) break;
allPosts = allPosts.concat(newPosts);
if (!data.next_cursor) break;
if (seenCursors[data.next_cursor]) break;
seenCursors[data.next_cursor] = true;
cursor = data.next_cursor;
}
btn.textContent = origText;
btn.disabled = false;
if (allPosts.length === 0) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-secondary);">Brak postów.</div>';
return;
}
renderFbPosts(companyId, allPosts, null, false);
container.insertAdjacentHTML('beforeend',
'<div style="text-align:center;margin-top:var(--spacing-md);color:var(--text-secondary);font-size:var(--font-size-sm);">Pobrano ' + allPosts.length + ' postów z Facebook API i zapisano do cache</div>');
renderFbCharts(companyId, allPosts, 'teraz (świeżo pobrane)');
saveFbPostsToCache(companyId, allPosts);
} catch (err) {
btn.textContent = origText;
btn.disabled = false;
showToast('Błąd połączenia: ' + err.message, 'error');
}
}
function renderFbCharts(companyId, posts, cachedAt) {
// Show cache date info
var infoEl = document.getElementById('fbChartsInfo-' + companyId);
if (infoEl) {
var dateStr = cachedAt || 'teraz';
infoEl.innerHTML = 'Wykresy na podstawie <strong>' + posts.length + '</strong> postów | Dane z: <strong>' + dateStr + '</strong> | Kliknij <strong>Odśwież wszystkie</strong> aby zaktualizować';
}
// Sort chronologically (oldest first)
var sorted = posts.slice().sort(function(a, b) {
return new Date(a.created_time) - new Date(b.created_time);
});
// Aggregate per month
var months = {};
sorted.forEach(function(p) {
var d = new Date(p.created_time);
var key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
if (!months[key]) months[key] = { count: 0, likes: 0, comments: 0, shares: 0, reactions: 0 };
months[key].count++;
months[key].likes += (p.likes || 0);
months[key].comments += (p.comments || 0);
months[key].shares += (p.shares || 0);
months[key].reactions += (p.reactions_total || 0);
});
var monthKeys = Object.keys(months).sort();
// Destroy old charts
if (window._fbCharts[companyId]) {
window._fbCharts[companyId].forEach(function(c) { c.destroy(); });
}
window._fbCharts[companyId] = [];
var baseOpts = {
responsive: true,
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 10, font: { size: 11 } } } },
scales: { x: { ticks: { font: { size: 10 }, maxRotation: 45 } }, y: { beginAtZero: true, ticks: { font: { size: 10 } } } }
};
// Chart 1: Engagement per post (line)
var ctx1 = document.getElementById('engagementChart-' + companyId).getContext('2d');
var postLabels = sorted.map(function(p) {
var d = new Date(p.created_time);
return d.toLocaleDateString('pl-PL', {day: '2-digit', month: '2-digit'});
});
var chart1 = new Chart(ctx1, {
type: 'line',
data: {
labels: postLabels,
datasets: [
{ label: 'Reakcje', data: sorted.map(function(p) { return p.reactions_total || 0; }), borderColor: '#e8a819', backgroundColor: 'rgba(232,168,25,0.1)', tension: 0.3, pointRadius: 2 },
{ label: 'Komentarze', data: sorted.map(function(p) { return p.comments || 0; }), borderColor: '#42b72a', backgroundColor: 'rgba(66,183,42,0.1)', tension: 0.3, pointRadius: 2 },
{ label: 'Udostepnienia', data: sorted.map(function(p) { return p.shares || 0; }), borderColor: '#f5533d', backgroundColor: 'rgba(245,83,61,0.1)', tension: 0.3, pointRadius: 2 }
]
},
options: Object.assign({}, baseOpts, { plugins: Object.assign({}, baseOpts.plugins, { title: { display: false } }) })
});
window._fbCharts[companyId].push(chart1);
// Chart 2: Posts per month (bar)
var ctx2 = document.getElementById('activityChart-' + companyId).getContext('2d');
var chart2 = new Chart(ctx2, {
type: 'bar',
data: {
labels: monthKeys,
datasets: [{
label: 'Liczba postów',
data: monthKeys.map(function(k) { return months[k].count; }),
backgroundColor: 'rgba(24,119,242,0.7)',
borderColor: '#1877f2',
borderWidth: 1,
borderRadius: 4
}]
},
options: baseOpts
});
window._fbCharts[companyId].push(chart2);
// Chart 3: Avg total engagement per month (line)
var ctx3 = document.getElementById('avgEngagementChart-' + companyId).getContext('2d');
var chart3 = new Chart(ctx3, {
type: 'line',
data: {
labels: monthKeys,
datasets: [{
label: 'Średni engagement / post',
data: monthKeys.map(function(k) {
var m = months[k];
var total = m.reactions + m.comments + m.shares;
return m.count > 0 ? Math.round(total / m.count * 10) / 10 : 0;
}),
borderColor: '#e8a819',
backgroundColor: 'rgba(232,168,25,0.15)',
tension: 0.3,
fill: true,
pointRadius: 3
}]
},
options: baseOpts
});
window._fbCharts[companyId].push(chart3);
// Chart 4: Post types (doughnut)
var typeCounts = {};
sorted.forEach(function(p) {
var t = p.status_type || 'other';
typeCounts[t] = (typeCounts[t] || 0) + 1;
});
var typeLabels = Object.keys(typeCounts);
var typeColors = ['#1877f2', '#42b72a', '#f5533d', '#e8a819', '#8b5cf6', '#ec4899', '#06b6d4'];
var ctx4 = document.getElementById('postTypesChart-' + companyId).getContext('2d');
var chart4 = new Chart(ctx4, {
type: 'doughnut',
data: {
labels: typeLabels,
datasets: [{
data: typeLabels.map(function(t) { return typeCounts[t]; }),
backgroundColor: typeColors.slice(0, typeLabels.length),
borderWidth: 2,
borderColor: 'var(--surface)'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 8, font: { size: 11 } } } }
}
});
window._fbCharts[companyId].push(chart4);
// Chart 5: Best day of week (bar — avg engagement per day)
var dayNames = ['Niedziela', 'Poniedzialek', 'Wtorek', 'Sroda', 'Czwartek', 'Piatek', 'Sobota'];
var dayData = Array.from({length: 7}, function() { return { eng: 0, count: 0 }; });
sorted.forEach(function(p) {
var dow = new Date(p.created_time).getDay();
dayData[dow].count++;
dayData[dow].eng += (p.reactions_total || 0) + (p.comments || 0) * 2 + (p.shares || 0) * 3;
});
// Reorder: Mon-Sun
var dayOrder = [1, 2, 3, 4, 5, 6, 0];
var ctx5 = document.getElementById('bestDayChart-' + companyId).getContext('2d');
var chart5 = new Chart(ctx5, {
type: 'bar',
data: {
labels: dayOrder.map(function(i) { return dayNames[i]; }),
datasets: [
{
label: 'Średni engagement',
data: dayOrder.map(function(i) { return dayData[i].count > 0 ? Math.round(dayData[i].eng / dayData[i].count * 10) / 10 : 0; }),
backgroundColor: 'rgba(24,119,242,0.7)',
borderColor: '#1877f2',
borderWidth: 1,
borderRadius: 4,
yAxisID: 'y'
},
{
label: 'Liczba postów',
data: dayOrder.map(function(i) { return dayData[i].count; }),
backgroundColor: 'rgba(232,168,25,0.5)',
borderColor: '#e8a819',
borderWidth: 1,
borderRadius: 4,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 8, font: { size: 11 } } } },
scales: {
x: { ticks: { font: { size: 10 } } },
y: { beginAtZero: true, position: 'left', title: { display: true, text: 'Avg engagement', font: { size: 10 } }, ticks: { font: { size: 10 } } },
y1: { beginAtZero: true, position: 'right', grid: { drawOnChartArea: false }, title: { display: true, text: 'Posty', font: { size: 10 } }, ticks: { font: { size: 10 } } }
}
}
});
window._fbCharts[companyId].push(chart5);
// Chart 6: Best hour (bar — avg engagement per hour)
var hourData = Array.from({length: 24}, function() { return { eng: 0, count: 0 }; });
sorted.forEach(function(p) {
var h = new Date(p.created_time).getHours();
hourData[h].count++;
hourData[h].eng += (p.reactions_total || 0) + (p.comments || 0) * 2 + (p.shares || 0) * 3;
});
var hourLabels = [];
var hourAvg = [];
var hourCounts = [];
for (var h = 0; h < 24; h++) {
if (hourData[h].count > 0) {
hourLabels.push(String(h).padStart(2, '0') + ':00');
hourAvg.push(Math.round(hourData[h].eng / hourData[h].count * 10) / 10);
hourCounts.push(hourData[h].count);
}
}
var ctx6 = document.getElementById('bestHourChart-' + companyId).getContext('2d');
var chart6 = new Chart(ctx6, {
type: 'bar',
data: {
labels: hourLabels,
datasets: [
{
label: 'Średni engagement',
data: hourAvg,
backgroundColor: 'rgba(66,183,42,0.7)',
borderColor: '#42b72a',
borderWidth: 1,
borderRadius: 4,
yAxisID: 'y'
},
{
label: 'Liczba postów',
data: hourCounts,
backgroundColor: 'rgba(232,168,25,0.5)',
borderColor: '#e8a819',
borderWidth: 1,
borderRadius: 4,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 8, font: { size: 11 } } } },
scales: {
x: { ticks: { font: { size: 10 } } },
y: { beginAtZero: true, position: 'left', title: { display: true, text: 'Avg engagement', font: { size: 10 } }, ticks: { font: { size: 10 } } },
y1: { beginAtZero: true, position: 'right', grid: { drawOnChartArea: false }, title: { display: true, text: 'Posty', font: { size: 10 } }, ticks: { font: { size: 10 } } }
}
}
});
window._fbCharts[companyId].push(chart6);
// Chart 7: Top 5 posts (horizontal bar)
var scored = sorted.map(function(p) {
return {
label: (p.message || '(bez tekstu)').substring(0, 60) + (p.message && p.message.length > 60 ? '...' : ''),
score: (p.reactions_total || 0) + (p.comments || 0) * 2 + (p.shares || 0) * 3,
reactions: p.reactions_total || 0,
comments: p.comments || 0,
shares: p.shares || 0,
url: p.permalink_url || null
};
}).sort(function(a, b) { return b.score - a.score; }).slice(0, 5);
// Split labels into 2-line arrays for compact display
var topLabels = scored.map(function(p) {
var t = p.label;
if (t.length > 30) return [t.substring(0, 30), t.substring(30)];
return [t];
});
var ctx7 = document.getElementById('topPostsChart-' + companyId).getContext('2d');
var chart7 = new Chart(ctx7, {
type: 'bar',
data: {
labels: topLabels,
datasets: [
{ label: 'Reakcje', data: scored.map(function(p) { return p.reactions; }), backgroundColor: '#e8a819', borderRadius: 3, barThickness: 14 },
{ label: 'Komentarze', data: scored.map(function(p) { return p.comments; }), backgroundColor: '#42b72a', borderRadius: 3, barThickness: 14 },
{ label: 'Udostepnienia', data: scored.map(function(p) { return p.shares; }), backgroundColor: '#f5533d', borderRadius: 3, barThickness: 14 }
]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, padding: 6, font: { size: 10 } } } },
scales: {
x: { stacked: true, beginAtZero: true, ticks: { font: { size: 10 } } },
y: { stacked: true, ticks: { font: { size: 9 }, autoSkip: false } }
},
onClick: function(evt, elements) {
if (elements.length > 0) {
var idx = elements[0].index;
var url = scored[idx].url;
if (url) window.open(url, '_blank');
}
},
onHover: function(evt, elements) {
evt.native.target.style.cursor = elements.length > 0 ? 'pointer' : 'default';
}
}
});
window._fbCharts[companyId].push(chart7);
// Show charts section
document.getElementById('fbChartsSection-' + companyId).style.display = 'block';
}
function syncFacebookData(companyId, btn) {
var origText = btn.innerHTML;
btn.disabled = true;
btn.textContent = 'Synchronizuję...';
fetch('/api/oauth/meta/sync-facebook', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': document.querySelector('meta[name=csrf-token]')?.content || ''},
body: JSON.stringify({company_id: companyId})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
btn.textContent = 'Zaktualizowano!';
setTimeout(function() { location.reload(); }, 800);
} else {
btn.innerHTML = origText;
btn.disabled = false;
showToast(data.message || 'Błąd synchronizacji', 'error');
}
})
.catch(function() {
btn.innerHTML = origText;
btn.disabled = false;
showToast('Błąd połączenia', 'error');
});
}
function saveFbPostsToCache(companyId, posts) {
fetch('/admin/social-publisher/fb-posts-cache/' + companyId, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': document.querySelector('meta[name=csrf-token]')?.content || ''},
body: JSON.stringify({posts: posts})
}).catch(function() {});
}
// Render cached posts preview + auto-load charts from DB cache
document.addEventListener('DOMContentLoaded', function() {
{% if cached_fb_posts %}
{% for cid, cache_data in cached_fb_posts.items() %}
(function() {
var companyId = {{ cid }};
var cachedPosts = {{ cache_data.posts | tojson }};
var total = {{ cache_data.total_count }};
if (cachedPosts && cachedPosts.length > 0) {
renderFbPosts(companyId, cachedPosts, null, false);
}
// Auto-load full cache via AJAX for charts (fast, from DB)
if (total > 0) {
fetch('/admin/social-publisher/fb-posts-cache/' + companyId)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success && data.posts && data.posts.length > 0) {
renderFbPosts(companyId, data.posts, null, false);
renderFbCharts(companyId, data.posts, data.cached_at);
}
}).catch(function() {});
}
})();
{% endfor %}
{% endif %}
});
{% endblock %}