Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
renderFbPosts no longer unconditionally resets page to 1. Only resets when dataset size changes (new data loaded). Preserves user's dropdown selection during auto-load. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1418 lines
64 KiB
HTML
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">👍 {{ post.engagement_likes or 0 }}</span>
|
|
<span title="Komentarze">💬 {{ post.engagement_comments or 0 }}</span>
|
|
<span title="Udostepnienia">🔁 {{ 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);">❓</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 || '❓';
|
|
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: '✓', error: '✗', warning: '⚠', info: 'ℹ' };
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast ' + type;
|
|
toast.innerHTML = '<span style="font-size:1.2em">' + (icons[type]||'ℹ') + '</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: '📣',
|
|
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: '🗑',
|
|
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 = 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 += ' · ' + post.status_type;
|
|
html += '</div>';
|
|
if (post.message) {
|
|
html += '<div class="fb-post-text">' + post.message.replace(/</g, '<').replace(/>/g, '>') + '</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)">❤️ ' + (post.reactions_total || 0) + '</span>';
|
|
html += '<span title="Komentarze">💬 ' + (post.comments || 0) + '</span>';
|
|
html += '<span title="Udostępnienia">🔁 ' + (post.shares || 0) + '</span>';
|
|
html += '<span title="Wynik engagement (reakcje + komentarze x2 + udostępnienia x3)" style="font-weight:600;color:var(--primary);">⚡ ' + 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">👁 Wyswietlenia: <strong>' + (ins.impressions != null ? ins.impressions : '-') + '</strong></span>';
|
|
html += '<span title="Zasieg">📊 Zasieg: <strong>' + (ins.reach != null ? ins.reach : '-') + '</strong></span>';
|
|
html += '<span title="Zaangazowani">👥 Zaangazowani: <strong>' + (ins.engaged_users != null ? ins.engaged_users : '-') + '</strong></span>';
|
|
html += '<span title="Klikniecia">🖱️ 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 %}
|