nordabiz/templates/admin/social_audit_enrichment_review.html
Maciej Pienczyn 60dd646989
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
feat: trigger Facebook Graph API sync during enrichment scan
Instead of skipping OAuth-connected Facebook profiles, the enrichment
scan now calls sync_facebook_to_social_media() to fetch fresh data
via Graph API.

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

482 lines
23 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Raport audytu Social Media - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.review-page { max-width: 1100px; margin: 0 auto; }
.review-header {
display: flex; justify-content: space-between; align-items: flex-start;
margin-bottom: var(--spacing-xl); flex-wrap: wrap; gap: var(--spacing-md);
}
.review-header h1 { font-size: var(--font-size-2xl); margin: 0; }
.review-actions { display: flex; gap: var(--spacing-sm); align-items: center; }
/* Summary cards */
.review-summary {
display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-md); margin-bottom: var(--spacing-xl);
}
.review-stat {
background: var(--surface); padding: var(--spacing-md);
border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); text-align: center;
}
.review-stat-value { font-size: var(--font-size-2xl); font-weight: 700; display: block; }
.review-stat-label { font-size: var(--font-size-sm); color: var(--text-secondary); }
.review-stat-value.green { color: #22c55e; }
.review-stat-value.yellow { color: #f59e0b; }
.review-stat-value.red { color: #ef4444; }
.review-stat-value.blue { color: #3b82f6; }
.review-stat-value.gray { color: #9ca3af; }
/* Company section */
.company-review {
background: var(--surface); border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm); margin-bottom: var(--spacing-md); overflow: hidden;
}
.company-review-header {
display: flex; align-items: center; gap: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-lg);
background: var(--background); cursor: pointer;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.company-review-header:hover { background: #f3f4f6; }
.company-review-header h3 { margin: 0; font-size: var(--font-size-base); flex: 1; }
.company-review-header .badge {
padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600;
}
.badge.changes { background: #dbeafe; color: #1d4ed8; }
.badge.error { background: #fee2e2; color: #991b1b; }
.badge.skipped { background: #f3f4f6; color: #6b7280; }
.company-review-body { padding: var(--spacing-md) var(--spacing-lg); }
/* Platform diff */
.platform-diff {
margin-bottom: var(--spacing-md); padding: var(--spacing-md);
background: var(--background); border-radius: var(--radius);
border-left: 3px solid #3b82f6;
}
.platform-diff.error { border-left-color: #ef4444; }
.platform-diff.skipped { border-left-color: #9ca3af; }
.platform-diff.no-changes { border-left-color: #22c55e; }
.platform-diff-header {
display: flex; align-items: center; gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm); font-weight: 600;
}
/* Changes table */
.changes-table { width: 100%; font-size: var(--font-size-sm); border-collapse: collapse; }
.changes-table th {
text-align: left; padding: 4px 8px; font-weight: 500;
color: var(--text-secondary); font-size: var(--font-size-xs);
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.changes-table td { padding: 6px 8px; border-bottom: 1px solid #f3f4f6; }
.changes-table .old-val { color: #991b1b; text-decoration: line-through; }
.changes-table .new-val { color: #15803d; font-weight: 500; }
.changes-table .arrow { color: var(--text-secondary); padding: 0 4px; }
/* Status message */
.approval-banner {
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius-lg); margin-bottom: var(--spacing-xl);
display: flex; align-items: center; gap: var(--spacing-md);
}
.approval-banner.pending {
background: #fef3c7; border: 1px solid #f59e0b; color: #92400e;
}
.approval-banner.approved {
background: #dcfce7; border: 1px solid #22c55e; color: #15803d;
}
.approval-banner.empty {
background: #f3f4f6; border: 1px solid #d1d5db; color: #6b7280;
}
.toggle-arrow { transition: transform 0.2s; display: inline-block; }
.toggle-arrow.open { transform: rotate(90deg); }
.hidden { display: none !important; }
/* No changes section */
.no-changes-section {
margin-top: var(--spacing-xl); padding: var(--spacing-md);
background: var(--background); border-radius: var(--radius-lg);
}
.no-changes-list {
display: flex; flex-wrap: wrap; gap: var(--spacing-xs);
font-size: var(--font-size-xs); color: var(--text-secondary);
}
.no-changes-list span {
background: var(--surface); padding: 2px 8px;
border-radius: var(--radius); border: 1px solid var(--border-color, #e5e7eb);
}
</style>
{% endblock %}
{% block content %}
<div class="review-page">
<a href="{{ url_for('admin.admin_social_audit') }}" class="back-link" style="display: inline-flex; align-items: center; gap: 4px; color: var(--text-secondary); text-decoration: none; font-size: var(--font-size-sm); margin-bottom: var(--spacing-md);">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Powrót do dashboardu
</a>
<div class="review-header">
<div>
<h1>Raport audytu Social Media</h1>
{% if summary.last_run %}
<p style="color: var(--text-secondary); font-size: var(--font-size-sm); margin: 4px 0 0;">
Skanowanie: {{ summary.last_run }}
</p>
{% endif %}
</div>
{% if not approved and summary.profiles_with_changes > 0 %}
<div class="review-actions">
<button class="btn btn-danger btn-sm" onclick="discardChanges()" id="discardBtn">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
Odrzuć wszystko
</button>
<button class="btn btn-primary" onclick="approveChanges()" id="approveBtn">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
Zatwierdź {{ summary.profiles_with_changes }} zmian
</button>
</div>
{% endif %}
</div>
<!-- Summary -->
<div class="review-summary">
<div class="review-stat">
<span class="review-stat-value">{{ summary.total_companies }}</span>
<span class="review-stat-label">Firm przeskanowanych</span>
</div>
<div class="review-stat">
<span class="review-stat-value">{{ summary.total_profiles_scanned }}</span>
<span class="review-stat-label">Profili sprawdzonych</span>
</div>
<div class="review-stat">
<span class="review-stat-value blue">{{ summary.profiles_with_changes }}</span>
<span class="review-stat-label">Z nowymi danymi</span>
</div>
<div class="review-stat">
<span class="review-stat-value blue">{{ summary.profiles_skipped }}</span>
<span class="review-stat-label">Sync API (OAuth)</span>
</div>
<div class="review-stat">
<span class="review-stat-value gray">{{ summary.profiles_no_data }}</span>
<span class="review-stat-label">Bez zmian</span>
</div>
<div class="review-stat">
<span class="review-stat-value red">{{ summary.profiles_errors }}</span>
<span class="review-stat-label">Błędów</span>
</div>
</div>
<!-- Approval banner -->
{% if approved %}
<div class="approval-banner approved">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<div>
<strong>Zmiany zostały zatwierdzone i zapisane do bazy danych.</strong>
<p style="margin: 4px 0 0; font-size: var(--font-size-sm);">Dane zostały zaktualizowane. Możesz wrócić do dashboardu lub uruchomić nowy audyt.</p>
</div>
</div>
{% elif summary.profiles_with_changes > 0 %}
<div class="approval-banner pending">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
<div>
<strong>{{ summary.profiles_with_changes }} profili oczekuje na zatwierdzenie.</strong>
<p style="margin: 4px 0 0; font-size: var(--font-size-sm);">
Przejrzyj zmiany poniżej. Dane NIE zostały jeszcze zapisane do bazy.
Kliknij <strong>Zatwierdź</strong> aby zapisać lub <strong>Odrzuć</strong> aby anulować.
</p>
</div>
</div>
{% else %}
<div class="approval-banner empty">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<div>
<strong>Audyt nie znalazł nowych danych do zaktualizowania.</strong>
<p style="margin: 4px 0 0; font-size: var(--font-size-sm);">Scraper nie pobrał nowych danych. Platformy jak Facebook, Instagram i LinkedIn blokują dostęp publiczny — podłącz OAuth API, aby pobierać dane z tych serwisów.</p>
</div>
</div>
{% endif %}
<!-- Changes per company -->
{% for result in results %}
<div class="company-review">
<div class="company-review-header" onclick="toggleCompany(this)">
<span class="toggle-arrow">&#9654;</span>
<h3>{{ result.company_name }}</h3>
{% set change_count = result.profiles|selectattr('status', 'eq', 'changes')|list|length %}
{% set error_count = result.profiles|selectattr('status', 'eq', 'error')|list|length %}
{% set skip_count = result.profiles|selectattr('status', 'eq', 'skipped')|list|length %}
{% if change_count > 0 %}
<span class="badge changes">{{ change_count }} zmian</span>
{% endif %}
{% if error_count > 0 %}
<span class="badge error">{{ error_count }} błędów</span>
{% endif %}
{% if skip_count > 0 %}
<span class="badge skipped">{{ skip_count }} pom.</span>
{% endif %}
</div>
<div class="company-review-body hidden">
{% for p in result.profiles %}
<div class="platform-diff {{ p.status if p.status in ('error', 'skipped') else ('no-changes' if p.status in ('no_changes', 'synced_api') else '') }}">
<div class="platform-diff-header">
<span style="text-transform: capitalize;">{{ p.platform }}</span>
{% if p.status == 'changes' %}
<span style="color: #3b82f6; font-size: var(--font-size-xs); font-weight: 400;">{{ p.changes|length }} pól do aktualizacji</span>
{% elif p.status == 'skipped' %}
<span style="color: #9ca3af; font-size: var(--font-size-xs); font-weight: 400;">{{ p.reason }}</span>
{% elif p.status == 'error' %}
<span style="color: #ef4444; font-size: var(--font-size-xs); font-weight: 400;">{{ p.reason }}</span>
{% elif p.status == 'no_data' %}
<span style="color: #9ca3af; font-size: var(--font-size-xs); font-weight: 400;">{{ p.get('reason', 'Brak nowych danych') }}</span>
{% elif p.status == 'synced_api' %}
<span style="color: #3b82f6; font-size: var(--font-size-xs); font-weight: 400;">{{ p.reason }}</span>
{% elif p.status == 'no_changes' %}
<span style="color: #22c55e; font-size: var(--font-size-xs); font-weight: 400;">Dane aktualne</span>
{% endif %}
{% if p.url %}
<a href="{{ p.url }}" target="_blank" rel="noopener" style="font-size: 11px; color: var(--text-secondary); margin-left: auto;">{{ p.url|truncate(50) }}</a>
{% endif %}
</div>
{% if p.status == 'changes' and p.changes %}
<table class="changes-table">
<thead>
<tr>
<th style="width: 30%;">Pole</th>
<th style="width: 30%;">Obecna wartość</th>
<th style="width: 5%;"></th>
<th style="width: 35%;">Nowa wartość</th>
</tr>
</thead>
<tbody>
{% for ch in p.changes %}
<tr>
<td>{{ ch.label }}</td>
<td class="old-val">{{ ch.old }}</td>
<td class="arrow">&rarr;</td>
<td class="new-val">{{ ch.new }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
<!-- Companies without changes (collapsed) -->
{% set no_change_companies = [] %}
{% for r in all_results %}
{% if not r.has_changes and not r.profiles|selectattr('status', 'eq', 'error')|list %}
{% if no_change_companies.append(r) %}{% endif %}
{% endif %}
{% endfor %}
{% if no_change_companies %}
<div class="no-changes-section">
<p style="font-size: var(--font-size-sm); font-weight: 500; margin: 0 0 var(--spacing-sm);">
{{ no_change_companies|length }} firm bez zmian:
</p>
<div class="no-changes-list">
{% for r in no_change_companies %}
<span>{{ r.company_name }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Bottom actions (fixed for long pages) -->
{% if not approved and summary.profiles_with_changes > 0 %}
<div style="position: sticky; bottom: 0; background: var(--surface); padding: var(--spacing-md); border-top: 2px solid var(--border-color, #e5e7eb); margin-top: var(--spacing-xl); display: flex; justify-content: space-between; align-items: center; border-radius: var(--radius-lg) var(--radius-lg) 0 0; box-shadow: 0 -4px 12px rgba(0,0,0,0.1);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">
{{ summary.profiles_with_changes }} profili ({{ summary.companies_with_changes }} firm) oczekuje na zatwierdzenie
</span>
<div style="display: flex; gap: var(--spacing-sm);">
<button class="btn btn-danger btn-sm" onclick="discardChanges()">Odrzuć</button>
<button class="btn btn-primary" onclick="approveChanges()">Zatwierdź i zapisz</button>
</div>
</div>
{% endif %}
</div>
<!-- Confirm Modal -->
<div class="modal-overlay" id="confirmModal">
<div class="modal-box">
<div class="modal-icon" id="modalIcon">
<svg width="28" height="28" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
</div>
<h3 class="modal-title" id="modalTitle"></h3>
<p class="modal-message" id="modalMessage"></p>
<p class="modal-detail" id="modalDetail"></p>
<div class="modal-actions" id="modalActions"></div>
</div>
</div>
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 3000; display: flex; flex-direction: column; gap: 10px;"></div>
<style>
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 2000; justify-content: center; align-items: center; animation: modalFadeIn 0.2s ease; }
.modal-overlay.active { display: flex; }
.modal-box { background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl); max-width: 440px; width: 90%; text-align: center; box-shadow: 0 20px 40px rgba(0,0,0,0.2); animation: modalSlideUp 0.3s ease; }
@keyframes modalFadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes modalSlideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.modal-icon { width: 56px; height: 56px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto var(--spacing-md); }
.modal-icon.warn { background: #fef3c7; color: #f59e0b; }
.modal-icon.danger { background: #fee2e2; color: #ef4444; }
.modal-icon.success { background: #dcfce7; color: #22c55e; }
.modal-title { font-size: var(--font-size-lg); font-weight: 600; color: var(--text-primary); margin-bottom: var(--spacing-sm); }
.modal-message { color: var(--text-secondary); margin-bottom: var(--spacing-xs); line-height: 1.5; }
.modal-detail { font-size: var(--font-size-sm); color: var(--text-secondary); margin-bottom: var(--spacing-lg); }
.modal-actions { display: flex; gap: var(--spacing-sm); justify-content: center; }
.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; max-width: 400px; }
.toast.success { border-left-color: #22c55e; }
.toast.error { border-left-color: #ef4444; }
@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 toggleCompany(header) {
var body = header.nextElementSibling;
var arrow = header.querySelector('.toggle-arrow');
body.classList.toggle('hidden');
arrow.classList.toggle('open');
}
// Toast
function showToast(message, type, duration) {
type = type || 'info'; duration = duration || 4000;
var icons = { success: '✓', error: '✕', warning: '⚠', info: '' };
var container = document.getElementById('toastContainer');
var 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(function() { toast.style.animation = 'toastOut 0.3s ease forwards'; setTimeout(function() { toast.remove(); }, 300); }, duration);
}
// Modal
function showModal(opts) {
var icon = document.getElementById('modalIcon');
icon.className = 'modal-icon ' + (opts.iconType || 'warn');
icon.innerHTML = opts.iconSvg || '<svg width="28" height="28" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>';
document.getElementById('modalTitle').textContent = opts.title;
document.getElementById('modalMessage').innerHTML = opts.message;
document.getElementById('modalDetail').textContent = opts.detail || '';
var actions = document.getElementById('modalActions');
actions.innerHTML = '';
(opts.buttons || []).forEach(function(b) {
var btn = document.createElement('button');
btn.className = 'btn ' + (b.cls || 'btn-outline');
btn.textContent = b.label;
btn.onclick = function() { closeModal(); if (b.action) b.action(); };
actions.appendChild(btn);
});
document.getElementById('confirmModal').classList.add('active');
}
function closeModal() {
document.getElementById('confirmModal').classList.remove('active');
}
document.getElementById('confirmModal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
function approveChanges() {
showModal({
title: 'Zatwierdź zmiany',
iconType: 'warn',
message: 'Czy na pewno chcesz zatwierdzić i zapisać zebrane dane do bazy?',
detail: 'Ta operacja zaktualizuje {{ summary.profiles_with_changes }} profili w {{ summary.companies_with_changes }} firmach.',
buttons: [
{ label: 'Anuluj', cls: 'btn-outline' },
{ label: 'Zatwierdź i zapisz', cls: 'btn-primary', action: doApprove }
]
});
}
function doApprove() {
var approveBtn = document.getElementById('approveBtn');
var discardBtn = document.getElementById('discardBtn');
if (approveBtn) { approveBtn.disabled = true; approveBtn.textContent = 'Zapisywanie...'; }
if (discardBtn) discardBtn.disabled = true;
fetch('{{ url_for("admin.admin_social_audit_enrichment_approve") }}', {
method: 'POST',
headers: {'X-CSRFToken': '{{ csrf_token() }}'},
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'approved') {
showToast('Zaktualizowano ' + data.applied + ' profili' + (data.errors > 0 ? ' (' + data.errors + ' błędów)' : ''), 'success', 5000);
setTimeout(function() { location.reload(); }, 1500);
} else {
showToast('Błąd: ' + (data.error || 'Nieznany błąd'), 'error');
if (approveBtn) { approveBtn.disabled = false; approveBtn.textContent = 'Zatwierdź'; }
if (discardBtn) discardBtn.disabled = false;
}
})
.catch(function(e) {
showToast('Błąd połączenia: ' + e.message, 'error');
if (approveBtn) { approveBtn.disabled = false; approveBtn.textContent = 'Zatwierdź'; }
if (discardBtn) discardBtn.disabled = false;
});
}
function discardChanges() {
showModal({
title: 'Odrzuć zmiany',
iconType: 'danger',
iconSvg: '<svg width="28" height="28" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>',
message: 'Czy na pewno chcesz odrzucić wszystkie zebrane dane?',
detail: 'Baza danych nie zostanie zmieniona.',
buttons: [
{ label: 'Anuluj', cls: 'btn-outline' },
{ label: 'Odrzuć', cls: 'btn-danger', action: doDiscard }
]
});
}
function doDiscard() {
fetch('{{ url_for("admin.admin_social_audit_enrichment_discard") }}', {
method: 'POST',
headers: {'X-CSRFToken': '{{ csrf_token() }}'},
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'discarded') {
showToast('Odrzucono ' + data.count + ' zmian. Baza bez zmian.', 'success');
setTimeout(function() { window.location.href = '{{ url_for("admin.admin_social_audit") }}'; }, 1500);
}
});
}
{% endblock %}