feat: enrichment review/approve flow - no data saved without admin approval
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

- Scraper now collects data to staging area (in-memory), NOT to database
- New review page (/admin/social-audit/enrichment-review) with:
  - Summary stats (scanned, changes, skipped, errors)
  - Per-company expandable sections with before→after diff per field
  - Pending approval banner with change count
  - Sticky bottom bar with Approve/Discard buttons
- Approve endpoint writes staged data to DB
- Discard endpoint clears staging without touching DB
- After scan completes, auto-redirect to review page
- Companies without changes shown in collapsed list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-12 08:45:36 +01:00
parent d3c81e4880
commit a63d74ad3a
4 changed files with 659 additions and 78 deletions

View File

@ -742,10 +742,26 @@ def admin_social_audit_detail(company_id):
# ============================================================
# SOCIAL MEDIA ENRICHMENT (run scraper from dashboard)
# SOCIAL MEDIA ENRICHMENT (collect → review → approve/reject)
# ============================================================
# In-memory status tracker for enrichment jobs
# Field labels for display
_FIELD_LABELS = {
'page_name': 'Nazwa strony',
'followers_count': 'Obserwujący',
'has_profile_photo': 'Zdjęcie profilowe',
'has_cover_photo': 'Zdjęcie w tle',
'has_bio': 'Bio/opis',
'profile_description': 'Opis profilu',
'posts_count_30d': 'Postów (30 dni)',
'posts_count_365d': 'Postów (rok)',
'last_post_date': 'Ostatni post',
'engagement_rate': 'Engagement rate',
'posting_frequency_score': 'Regularność',
'profile_completeness_score': 'Kompletność profilu',
}
# In-memory staging area — data collected but NOT yet saved to DB
_enrichment_status = {
'running': False,
'progress': 0,
@ -753,21 +769,43 @@ _enrichment_status = {
'completed': 0,
'errors': 0,
'last_run': None,
'results': [],
'results': [], # Per-company results with before/after diffs
'pending_changes': [], # Changes awaiting approval
'approved': False,
}
def _format_value(key, val):
"""Format a field value for display."""
if val is None:
return ''
if isinstance(val, bool):
return 'Tak' if val else 'Nie'
if key == 'last_post_date' and hasattr(val, 'strftime'):
return val.strftime('%d.%m.%Y')
if key == 'engagement_rate':
return f'{val}%'
if key == 'posting_frequency_score':
return f'{val}/10'
if key == 'profile_completeness_score':
return f'{val}%'
if key == 'followers_count' and isinstance(val, (int, float)):
return f'{int(val):,}'.replace(',', ' ')
if key == 'profile_description' and isinstance(val, str) and len(val) > 80:
return val[:80] + '...'
return str(val)
def _run_enrichment_background(company_ids):
"""Run social media profile enrichment in background thread."""
"""Collect enrichment data into staging area (NO database writes)."""
import sys
from pathlib import Path
# Import enricher from scripts
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / 'scripts'))
try:
from social_media_audit import SocialProfileEnricher
except ImportError:
logger.error("Could not import SocialProfileEnricher from scripts/social_media_audit.py")
logger.error("Could not import SocialProfileEnricher")
_enrichment_status['running'] = False
return
@ -777,6 +815,10 @@ def _run_enrichment_background(company_ids):
_enrichment_status['completed'] = 0
_enrichment_status['errors'] = 0
_enrichment_status['results'] = []
_enrichment_status['pending_changes'] = []
_enrichment_status['approved'] = False
tracked_fields = list(_FIELD_LABELS.keys())
try:
for company_id in company_ids:
@ -790,74 +832,80 @@ def _run_enrichment_background(company_ids):
CompanySocialMedia.is_valid == True
).all()
company_results = []
company_result = {
'company_id': company_id,
'company_name': company.name,
'profiles': [],
'has_changes': False,
}
for profile in profiles:
# Skip if source is from API (higher priority)
profile_result = {
'profile_id': profile.id,
'platform': profile.platform,
'url': profile.url,
'source': profile.source,
}
# Skip API-sourced profiles
if profile.source in ('facebook_api',):
company_results.append({
'platform': profile.platform,
'status': 'skipped',
'reason': 'API data (higher priority)',
})
profile_result['status'] = 'skipped'
profile_result['reason'] = 'Dane z API (wyższy priorytet)'
company_result['profiles'].append(profile_result)
continue
try:
enriched = enricher.enrich_profile(profile.platform, profile.url)
if enriched:
if enriched.get('page_name'):
profile.page_name = enriched['page_name']
if enriched.get('followers_count') is not None:
profile.followers_count = enriched['followers_count']
if enriched.get('has_profile_photo') is not None:
profile.has_profile_photo = enriched['has_profile_photo']
if enriched.get('has_cover_photo') is not None:
profile.has_cover_photo = enriched['has_cover_photo']
if enriched.get('has_bio') is not None:
profile.has_bio = enriched['has_bio']
if enriched.get('profile_description'):
profile.profile_description = enriched['profile_description']
if enriched.get('posts_count_30d') is not None:
profile.posts_count_30d = enriched['posts_count_30d']
if enriched.get('posts_count_365d') is not None:
profile.posts_count_365d = enriched['posts_count_365d']
if enriched.get('last_post_date') is not None:
profile.last_post_date = enriched['last_post_date']
if enriched.get('engagement_rate') is not None:
profile.engagement_rate = enriched['engagement_rate']
if enriched.get('posting_frequency_score') is not None:
profile.posting_frequency_score = enriched['posting_frequency_score']
if enriched.get('profile_completeness_score') is not None:
profile.profile_completeness_score = enriched['profile_completeness_score']
# Build before/after diff
changes = []
for field in tracked_fields:
new_val = enriched.get(field)
if new_val is None:
continue
old_val = getattr(profile, field, None)
# Only record actual changes
if old_val != new_val:
changes.append({
'field': field,
'label': _FIELD_LABELS.get(field, field),
'old': _format_value(field, old_val),
'new': _format_value(field, new_val),
'old_raw': str(old_val) if old_val is not None else None,
'new_raw': str(new_val) if new_val is not None else None,
})
profile.last_checked_at = datetime.now()
db.commit()
profile_result['status'] = 'changes' if changes else 'no_changes'
profile_result['changes'] = changes
profile_result['enriched_data'] = {
k: (str(v) if hasattr(v, 'strftime') else v)
for k, v in enriched.items()
if k in tracked_fields and v is not None
}
company_results.append({
'platform': profile.platform,
'status': 'enriched',
'fields': list(enriched.keys()),
})
if changes:
company_result['has_changes'] = True
_enrichment_status['pending_changes'].append({
'profile_id': profile.id,
'company_id': company_id,
'company_name': company.name,
'platform': profile.platform,
'enriched_data': profile_result['enriched_data'],
'changes': changes,
})
else:
profile.last_checked_at = datetime.now()
db.commit()
company_results.append({
'platform': profile.platform,
'status': 'no_data',
})
profile_result['status'] = 'no_data'
profile_result['reason'] = 'Scraper nie zebrał danych'
except Exception as e:
logger.warning(f"Enrichment failed for {company.name}/{profile.platform}: {e}")
company_results.append({
'platform': profile.platform,
'status': 'error',
'error': str(e)[:100],
})
profile_result['status'] = 'error'
profile_result['reason'] = str(e)[:150]
_enrichment_status['errors'] += 1
_enrichment_status['results'].append({
'company_id': company_id,
'company_name': company.name,
'profiles': company_results,
})
company_result['profiles'].append(profile_result)
_enrichment_status['results'].append(company_result)
except Exception as e:
logger.error(f"Enrichment error for company {company_id}: {e}")
@ -871,20 +919,22 @@ def _run_enrichment_background(company_ids):
db.close()
_enrichment_status['running'] = False
_enrichment_status['last_run'] = datetime.now()
logger.info(f"Enrichment completed: {_enrichment_status['completed']}/{_enrichment_status['total']}, errors: {_enrichment_status['errors']}")
total_changes = len(_enrichment_status['pending_changes'])
logger.info(f"Enrichment scan completed: {_enrichment_status['completed']}/{_enrichment_status['total']}, "
f"{total_changes} pending changes, {_enrichment_status['errors']} errors")
@bp.route('/social-audit/run-enrichment', methods=['POST'])
@login_required
@role_required(SystemRole.ADMIN)
def admin_social_audit_run_enrichment():
"""Start social media profile enrichment for selected or all companies."""
"""Start social media profile enrichment scan (collect only, no DB writes)."""
if not is_audit_owner():
return jsonify({'error': 'Brak uprawnień'}), 403
if _enrichment_status['running']:
return jsonify({
'error': 'Enrichment już działa',
'error': 'Audyt już działa',
'progress': _enrichment_status['progress'],
'completed': _enrichment_status['completed'],
'total': _enrichment_status['total'],
@ -908,6 +958,8 @@ def admin_social_audit_run_enrichment():
_enrichment_status['running'] = True
_enrichment_status['progress'] = 0
_enrichment_status['results'] = []
_enrichment_status['pending_changes'] = []
_enrichment_status['approved'] = False
thread = threading.Thread(
target=_run_enrichment_background,
@ -919,7 +971,7 @@ def admin_social_audit_run_enrichment():
return jsonify({
'status': 'started',
'total': len(company_ids),
'message': f'Rozpoczęto audyt {len(company_ids)} firm w tle.',
'message': f'Rozpoczęto skanowanie {len(company_ids)} firm. Dane NIE zostaną zapisane bez Twojej zgody.',
})
@ -927,7 +979,8 @@ def admin_social_audit_run_enrichment():
@login_required
@role_required(SystemRole.OFFICE_MANAGER)
def admin_social_audit_enrichment_status():
"""Get current enrichment job status."""
"""Get current enrichment job status with pending changes summary."""
pending = _enrichment_status.get('pending_changes', [])
return jsonify({
'running': _enrichment_status['running'],
'progress': _enrichment_status['progress'],
@ -935,5 +988,141 @@ def admin_social_audit_enrichment_status():
'total': _enrichment_status['total'],
'errors': _enrichment_status['errors'],
'last_run': _enrichment_status['last_run'].strftime('%d.%m.%Y %H:%M') if _enrichment_status['last_run'] else None,
'results': _enrichment_status['results'][-10:],
'pending_count': len(pending),
'approved': _enrichment_status.get('approved', False),
})
@bp.route('/social-audit/enrichment-review')
@login_required
@role_required(SystemRole.ADMIN)
def admin_social_audit_enrichment_review():
"""Review page showing all collected changes before applying."""
if not is_audit_owner():
from flask import abort
abort(404)
if _enrichment_status['running']:
flash('Audyt wciąż trwa. Poczekaj na zakończenie.', 'warning')
return redirect(url_for('admin.admin_social_audit'))
results = _enrichment_status.get('results', [])
pending = _enrichment_status.get('pending_changes', [])
# Summary stats
total_profiles_scanned = sum(len(r['profiles']) for r in results)
profiles_with_changes = len(pending)
profiles_skipped = sum(1 for r in results for p in r['profiles'] if p.get('status') == 'skipped')
profiles_no_data = sum(1 for r in results for p in r['profiles'] if p.get('status') in ('no_data', 'no_changes'))
profiles_errors = sum(1 for r in results for p in r['profiles'] if p.get('status') == 'error')
companies_with_changes = len(set(c['company_id'] for c in pending))
summary = {
'total_companies': _enrichment_status.get('total', 0),
'total_profiles_scanned': total_profiles_scanned,
'profiles_with_changes': profiles_with_changes,
'profiles_skipped': profiles_skipped,
'profiles_no_data': profiles_no_data,
'profiles_errors': profiles_errors,
'companies_with_changes': companies_with_changes,
'last_run': _enrichment_status.get('last_run'),
}
# Only show companies that have changes or errors
results_to_show = [r for r in results if r.get('has_changes') or
any(p.get('status') == 'error' for p in r.get('profiles', []))]
return render_template('admin/social_audit_enrichment_review.html',
results=results_to_show,
all_results=results,
summary=summary,
approved=_enrichment_status.get('approved', False),
)
@bp.route('/social-audit/enrichment-approve', methods=['POST'])
@login_required
@role_required(SystemRole.ADMIN)
def admin_social_audit_enrichment_approve():
"""Apply all pending enrichment changes to database."""
if not is_audit_owner():
return jsonify({'error': 'Brak uprawnień'}), 403
if _enrichment_status['running']:
return jsonify({'error': 'Audyt wciąż trwa'}), 409
pending = _enrichment_status.get('pending_changes', [])
if not pending:
return jsonify({'error': 'Brak oczekujących zmian do zatwierdzenia'}), 400
if _enrichment_status.get('approved'):
return jsonify({'error': 'Zmiany zostały już zatwierdzone'}), 409
db = SessionLocal()
applied = 0
errors = 0
try:
for change in pending:
try:
profile = db.query(CompanySocialMedia).filter_by(id=change['profile_id']).first()
if not profile:
errors += 1
continue
enriched = change['enriched_data']
for field, value in enriched.items():
if field == 'last_post_date' and isinstance(value, str):
try:
from dateutil.parser import parse as parse_date
value = parse_date(value)
except (ImportError, ValueError):
continue
if field == 'engagement_rate' and isinstance(value, str):
value = float(value)
setattr(profile, field, value)
profile.last_checked_at = datetime.now()
applied += 1
except Exception as e:
logger.error(f"Failed to apply enrichment for profile {change.get('profile_id')}: {e}")
errors += 1
db.commit()
_enrichment_status['approved'] = True
logger.info(f"Enrichment approved: {applied} profiles updated, {errors} errors")
flash(f'Zatwierdzone: zaktualizowano {applied} profili.', 'success')
return jsonify({
'status': 'approved',
'applied': applied,
'errors': errors,
'message': f'Zaktualizowano {applied} profili.',
})
except Exception as e:
db.rollback()
logger.error(f"Enrichment approval failed: {e}")
return jsonify({'error': f'Błąd zapisu: {str(e)[:200]}'}), 500
finally:
db.close()
@bp.route('/social-audit/enrichment-discard', methods=['POST'])
@login_required
@role_required(SystemRole.ADMIN)
def admin_social_audit_enrichment_discard():
"""Discard all pending enrichment changes."""
if not is_audit_owner():
return jsonify({'error': 'Brak uprawnień'}), 403
count = len(_enrichment_status.get('pending_changes', []))
_enrichment_status['pending_changes'] = []
_enrichment_status['results'] = []
_enrichment_status['approved'] = False
flash(f'Odrzucono {count} oczekujących zmian. Baza danych nie została zmieniona.', 'info')
return jsonify({
'status': 'discarded',
'count': count,
})

View File

@ -990,7 +990,7 @@ function resetFilters() {
// Enrichment
function startEnrichment() {
if (!confirm('Uruchomić audyt social media dla wszystkich firm?\n\nProces działa w tle i może potrwać kilka minut.\nDane z API (OAuth) nie zostaną nadpisane.')) return;
if (!confirm('Uruchomić skanowanie social media dla wszystkich firm?\n\nProces działa w tle i może potrwać kilka minut.\nDane NIE zostaną zapisane bez Twojej zgody — po zakończeniu zobaczysz raport ze zmianami.\nProfile z danymi z API (OAuth) nie będą nadpisywane.')) return;
var btn = document.getElementById('enrichBtn');
var progress = document.getElementById('enrichProgress');
@ -1005,7 +1005,7 @@ function startEnrichment() {
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'started') {
document.getElementById('enrichText').textContent = 'Audyt: 0/' + data.total;
document.getElementById('enrichText').textContent = 'Skanowanie: 0/' + data.total;
pollEnrichment();
} else {
alert(data.error || 'Błąd uruchamiania');
@ -1027,21 +1027,27 @@ function pollEnrichment() {
.then(function(r) { return r.json(); })
.then(function(data) {
document.getElementById('enrichPct').textContent = data.progress + '%';
document.getElementById('enrichText').textContent = 'Audyt: ' + data.completed + '/' + data.total;
document.getElementById('enrichText').textContent = 'Skanowanie: ' + data.completed + '/' + data.total;
if (data.running) {
setTimeout(pollEnrichment, 3000);
} else {
var btn = document.getElementById('enrichBtn');
btn.disabled = false;
btn.innerHTML = '<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg> Uruchom audyt';
// Scan complete — redirect to review page
if (data.pending_count > 0) {
document.getElementById('enrichText').textContent = data.pending_count + ' zmian do zatwierdzenia...';
window.location.href = '{{ url_for("admin.admin_social_audit_enrichment_review") }}';
} else {
var btn = document.getElementById('enrichBtn');
btn.disabled = false;
btn.innerHTML = '<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg> Uruchom audyt';
var errInfo = data.errors > 0 ? ', ' + data.errors + ' błędów' : '';
document.getElementById('enrichText').textContent = 'Zakończono: ' + data.completed + '/' + data.total + errInfo;
var errInfo = data.errors > 0 ? ', ' + data.errors + ' błędów' : '';
document.getElementById('enrichText').textContent = 'Brak nowych danych' + errInfo;
setTimeout(function() {
document.getElementById('enrichProgress').style.display = 'none';
}, 10000);
setTimeout(function() {
document.getElementById('enrichProgress').style.display = 'none';
}, 8000);
}
}
});
}

View File

@ -907,8 +907,13 @@ function pollSingleEnrichment() {
.then(function(data) {
if (data.running) {
setTimeout(pollSingleEnrichment, 2000);
} else if (data.pending_count > 0) {
window.location.href = '{{ url_for("admin.admin_social_audit_enrichment_review") }}';
} else {
location.reload();
var btn = document.getElementById('enrichSingleBtn');
btn.disabled = false;
btn.textContent = 'Brak nowych danych';
setTimeout(function() { btn.textContent = 'Uruchom audyt'; }, 5000);
}
});
}

View File

@ -0,0 +1,381 @@
{% 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.strftime('%d.%m.%Y %H:%M') }}
</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 gray">{{ summary.profiles_skipped }}</span>
<span class="review-stat-label">Pominiętych (API)</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 zebrał żadnych nowych informacji. Profie mogą być zablokowane lub dane są już aktualne.</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 == 'no_changes' 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 == '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>
{% 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');
}
function approveChanges() {
if (!confirm('Czy na pewno chcesz zatwierdzić i zapisać zebrane dane do bazy?\n\nTa operacja zaktualizuje {{ summary.profiles_with_changes }} profili w {{ summary.companies_with_changes }} firmach.')) return;
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') {
alert('Zatwierdzone! Zaktualizowano ' + data.applied + ' profili.' + (data.errors > 0 ? ' (' + data.errors + ' błędów)' : ''));
location.reload();
} else {
alert('Błąd: ' + (data.error || 'Nieznany błąd'));
if (approveBtn) { approveBtn.disabled = false; approveBtn.textContent = 'Zatwierdź'; }
if (discardBtn) discardBtn.disabled = false;
}
})
.catch(function(e) {
alert('Błąd: ' + e.message);
if (approveBtn) { approveBtn.disabled = false; approveBtn.textContent = 'Zatwierdź'; }
if (discardBtn) discardBtn.disabled = false;
});
}
function discardChanges() {
if (!confirm('Czy na pewno chcesz ODRZUCIĆ wszystkie zebrane dane?\n\nBaza danych nie zostanie zmieniona.')) return;
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') {
alert('Odrzucono ' + data.count + ' zmian. Baza danych nie została zmieniona.');
window.location.href = '{{ url_for("admin.admin_social_audit") }}';
}
});
}
{% endblock %}