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
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1218 lines
54 KiB
HTML
1218 lines
54 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Social Media Publisher - 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 Publisher</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>Ostatnie posty na Facebook</h3>
|
|
<div style="display: flex; gap: var(--spacing-xs);">
|
|
<button class="btn btn-secondary btn-small" onclick="loadFbPosts({{ company_id_key }}, this)">Zaladuj posty</button>
|
|
<button class="btn btn-primary btn-small" onclick="loadAllFbPosts({{ company_id_key }}, this)">Zaladuj wszystkie + wykresy</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><canvas id="topPostsChart-{{ company_id_key }}"></canvas></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);
|
|
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-secondary);">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 wszystkie posty (' + allPosts.length + ')</div>');
|
|
renderFbCharts(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, 50) + (p.message && p.message.length > 50 ? '...' : ''),
|
|
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
|
|
};
|
|
}).sort(function(a, b) { return b.score - a.score; }).slice(0, 5);
|
|
var ctx7 = document.getElementById('topPostsChart-' + companyId).getContext('2d');
|
|
var chart7 = new Chart(ctx7, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: scored.map(function(p) { return p.label; }),
|
|
datasets: [
|
|
{ label: 'Reakcje', data: scored.map(function(p) { return p.reactions; }), backgroundColor: '#e8a819', borderRadius: 4 },
|
|
{ label: 'Komentarze', data: scored.map(function(p) { return p.comments; }), backgroundColor: '#42b72a', borderRadius: 4 },
|
|
{ label: 'Udostepnienia', data: scored.map(function(p) { return p.shares; }), backgroundColor: '#f5533d', borderRadius: 4 }
|
|
]
|
|
},
|
|
options: {
|
|
indexAxis: 'y',
|
|
responsive: true,
|
|
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 8, font: { size: 11 } } } },
|
|
scales: {
|
|
x: { stacked: true, beginAtZero: true, ticks: { font: { size: 10 } } },
|
|
y: { stacked: true, ticks: { font: { size: 10 }, callback: function(val) { return this.getLabelForValue(val).substring(0, 35); } } }
|
|
}
|
|
}
|
|
});
|
|
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');
|
|
});
|
|
}
|
|
{% endblock %}
|