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
"Najnowsze 10" no longer saves to DB cache. New "Odswiez wszystkie" button fetches all posts via FB API pagination and saves to cache. "Analityka" uses cache for instant charts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1338 lines
60 KiB
HTML
1338 lines
60 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 }} postow, 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-secondary btn-small" onclick="refreshAllFbPosts({{ company_id_key }}, this)">Odswiez wszystkie</button>
|
|
<button class="btn btn-primary btn-small" onclick="loadAllFbPosts({{ company_id_key }}, this)">Analityka</button>
|
|
</div>
|
|
</div>
|
|
<div id="fbChartsSection-{{ company_id_key }}" style="display:none;">
|
|
<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>Aktywnosc publikacji</h4><canvas id="activityChart-{{ company_id_key }}"></canvas></div>
|
|
<div class="fb-chart-card"><h4>Sredni engagement / miesiac</h4><canvas id="avgEngagementChart-{{ company_id_key }}"></canvas></div>
|
|
<div class="fb-chart-card"><h4>Typy postow</h4><canvas id="postTypesChart-{{ company_id_key }}"></canvas></div>
|
|
<div class="fb-chart-card"><h4>Najlepszy dzien 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 postow</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 postow -->
|
|
<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 }})">Usun</button>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% else %}
|
|
<div class="empty-state">
|
|
<p>Brak postow{% if status_filter != 'all' or type_filter != 'all' %} pasujacych do filtrow{% endif %}.</p>
|
|
<p style="margin-top: var(--spacing-md);">
|
|
<a href="{{ url_for('admin.social_publisher_new') }}" class="btn btn-primary">Utworz 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: 'Usun',
|
|
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'});
|
|
}
|
|
|
|
function renderFbPosts(companyId, posts, nextCursor, append) {
|
|
var container = document.getElementById('fbPostsContainer-' + companyId);
|
|
var html = '';
|
|
posts.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="Udostepnienia">🔁 ' + (post.shares || 0) + '</span>';
|
|
html += '<span title="Wynik engagement (reakcje + komentarze x2 + udostepnienia 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>';
|
|
});
|
|
|
|
// Remove old "load more" button before appending
|
|
var oldMore = document.getElementById('fbLoadMore-' + companyId);
|
|
if (oldMore) oldMore.remove();
|
|
|
|
if (append) {
|
|
container.insertAdjacentHTML('beforeend', html);
|
|
} else {
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
// Add "load more" button if there's a next page
|
|
if (nextCursor) {
|
|
var moreHtml = '<div id="fbLoadMore-' + companyId + '" style="text-align:center;margin-top:var(--spacing-md);">';
|
|
moreHtml += '<button class="btn btn-secondary" onclick="loadFbPosts(' + companyId + ', this, \'' + nextCursor + '\')">Nastepna strona →</button>';
|
|
moreHtml += '</div>';
|
|
container.insertAdjacentHTML('beforeend', moreHtml);
|
|
}
|
|
}
|
|
|
|
function loadFbPosts(companyId, btn, afterCursor) {
|
|
var origText = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = 'Ladowanie...';
|
|
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 postow 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 || 'Blad') + '</div>';
|
|
} else {
|
|
showToast(data.error || 'Blad 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 postow 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 juz 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);">Blad polaczenia: ' + err.message + '</div>';
|
|
} else {
|
|
showToast('Blad polaczenia: ' + 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 = 'Ladowanie...';
|
|
container.innerHTML = '<div class="fb-post-insights">Pobieranie insights...</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);">Blad: ' + 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 = 'Ladowanie...';
|
|
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);">Wyswietlono ' + cacheData.posts.length + ' postow z cache</div>');
|
|
renderFbCharts(companyId, cacheData.posts);
|
|
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 ladowanie — pobieranie postow z Facebook API...</div>';
|
|
|
|
var allPosts = [];
|
|
var cursor = null;
|
|
var page = 0;
|
|
|
|
try {
|
|
while (true) {
|
|
page++;
|
|
btn.textContent = 'Ladowanie... (' + allPosts.length + ' postow, 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 || 'Blad pobierania', 'error');
|
|
break;
|
|
}
|
|
|
|
if (!data.posts || data.posts.length === 0) break;
|
|
|
|
allPosts = allPosts.concat(data.posts);
|
|
|
|
if (!data.next_cursor) break;
|
|
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 postow 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);">Zaladowano ' + allPosts.length + ' postow z Facebook API</div>');
|
|
renderFbCharts(companyId, allPosts);
|
|
saveFbPostsToCache(companyId, allPosts);
|
|
} catch (err) {
|
|
btn.textContent = origText;
|
|
btn.disabled = false;
|
|
showToast('Blad polaczenia: ' + 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 postow z Facebook API...</div>';
|
|
|
|
var allPosts = [];
|
|
var cursor = null;
|
|
var page = 0;
|
|
|
|
try {
|
|
while (true) {
|
|
page++;
|
|
btn.textContent = 'Pobieranie... (' + allPosts.length + ' postow, 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;
|
|
|
|
allPosts = allPosts.concat(data.posts);
|
|
|
|
if (!data.next_cursor) break;
|
|
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 postow.</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 + ' postow z Facebook API i zapisano do cache</div>');
|
|
saveFbPostsToCache(companyId, allPosts);
|
|
} catch (err) {
|
|
btn.textContent = origText;
|
|
btn.disabled = false;
|
|
showToast('Blad polaczenia: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
function renderFbCharts(companyId, posts) {
|
|
// 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 postow',
|
|
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: 'Sredni 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,
|
|
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: 'Sredni 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 postow',
|
|
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: 'Sredni engagement',
|
|
data: hourAvg,
|
|
backgroundColor: 'rgba(66,183,42,0.7)',
|
|
borderColor: '#42b72a',
|
|
borderWidth: 1,
|
|
borderRadius: 4,
|
|
yAxisID: 'y'
|
|
},
|
|
{
|
|
label: 'Liczba postow',
|
|
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 on page load (max 10, no charts)
|
|
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 }};
|
|
if (cachedPosts && cachedPosts.length > 0) {
|
|
renderFbPosts(companyId, cachedPosts, null, false);
|
|
var total = {{ cache_data.total_count }};
|
|
if (total > cachedPosts.length) {
|
|
document.getElementById('fbPostsContainer-' + companyId).insertAdjacentHTML('beforeend',
|
|
'<div style="text-align:center;margin-top:var(--spacing-md);color:var(--text-secondary);font-size:var(--font-size-sm);">Wyswietlono ' + cachedPosts.length + ' z ' + total + ' postow. Kliknij <strong>Analityka</strong> aby zobaczyc wszystkie z wykresami.</div>');
|
|
}
|
|
}
|
|
})();
|
|
{% endfor %}
|
|
{% endif %}
|
|
});
|
|
{% endblock %}
|