feat: add review-before-save workflow to GBP batch audit
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

Batch audit now collects changes without saving to DB. Admin must
review before/after differences and approve or discard. Mirrors the
existing social audit enrichment review pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-13 12:43:33 +01:00
parent 753a84dff2
commit 1d39c9190a
3 changed files with 678 additions and 8 deletions

View File

@ -40,6 +40,8 @@ _GBP_BATCH_DEFAULT = {
'completed': 0,
'errors': 0,
'results': [],
'pending_changes': [],
'approved': False,
}
@ -66,8 +68,52 @@ def _write_gbp_batch_state(state):
logger.error(f"Failed to write GBP batch state: {e}")
def _get_old_audit(db, company_id):
"""Get latest existing GBP audit for comparison."""
from sqlalchemy import func
audit = (
db.query(GBPAudit)
.filter_by(company_id=company_id)
.order_by(GBPAudit.audit_date.desc())
.first()
)
if not audit:
return None
return {
'score': audit.completeness_score,
'rating': float(audit.average_rating) if audit.average_rating else None,
'review_count': audit.review_count or 0,
'photo_count': audit.photo_count or 0,
'has_name': audit.has_name,
'has_address': audit.has_address,
'has_phone': audit.has_phone,
'has_website': audit.has_website,
'has_hours': audit.has_hours,
'has_categories': audit.has_categories,
'has_photos': audit.has_photos,
'has_description': audit.has_description,
'has_services': audit.has_services,
'has_reviews': audit.has_reviews,
'audit_date': str(audit.audit_date) if audit.audit_date else None,
}
_GBP_FIELD_LABELS = {
'has_name': 'Nazwa firmy',
'has_address': 'Adres',
'has_phone': 'Telefon',
'has_website': 'Strona WWW',
'has_hours': 'Godziny otwarcia',
'has_categories': 'Kategorie',
'has_photos': 'Zdjęcia',
'has_description': 'Opis',
'has_services': 'Usługi',
'has_reviews': 'Opinie',
}
def _run_gbp_batch_background(company_ids, fetch_google):
"""Background thread: audit all companies one by one."""
"""Background thread: audit all companies, collect changes without saving."""
from gbp_audit_service import GBPAuditService, fetch_google_business_data
db = SessionLocal()
@ -81,17 +127,113 @@ def _run_gbp_batch_background(company_ids, fetch_google):
company = db.get(Company, company_id)
company_name = company.name if company else f'ID {company_id}'
# Get old audit for comparison
old_audit = _get_old_audit(db, company_id)
if fetch_google:
fetch_google_business_data(db, company_id, force_refresh=True)
result = service.audit_company(company_id)
service.save_audit(result, source='automated')
# DO NOT save — collect for review
# Build new audit data
new_audit = {
'score': result.completeness_score,
'rating': float(result.average_rating) if result.average_rating else None,
'review_count': result.review_count or 0,
'photo_count': result.photo_count or 0,
}
for field_key in _GBP_FIELD_LABELS:
field_name = field_key.replace('has_', '')
field_status = result.fields.get(field_name)
new_audit[field_key] = field_status.status in ('complete', 'partial') if field_status else False
# Detect changes
changes = []
if old_audit:
if old_audit['score'] != new_audit['score']:
changes.append({
'field': 'score', 'label': 'Kompletność',
'old': f"{old_audit['score']}%" if old_audit['score'] is not None else 'brak',
'new': f"{new_audit['score']}%",
})
if old_audit['rating'] != new_audit['rating']:
changes.append({
'field': 'rating', 'label': 'Ocena Google',
'old': str(old_audit['rating'] or '-'),
'new': str(new_audit['rating'] or '-'),
})
if old_audit['review_count'] != new_audit['review_count']:
changes.append({
'field': 'review_count', 'label': 'Liczba opinii',
'old': str(old_audit['review_count']),
'new': str(new_audit['review_count']),
})
if old_audit['photo_count'] != new_audit['photo_count']:
changes.append({
'field': 'photo_count', 'label': 'Liczba zdjęć',
'old': str(old_audit['photo_count']),
'new': str(new_audit['photo_count']),
})
for field_key, label in _GBP_FIELD_LABELS.items():
old_val = old_audit.get(field_key)
new_val = new_audit.get(field_key)
if old_val != new_val:
changes.append({
'field': field_key, 'label': label,
'old': 'Tak' if old_val else 'Nie',
'new': 'Tak' if new_val else 'Nie',
})
else:
# First audit — mark as new
changes.append({
'field': 'score', 'label': 'Kompletność',
'old': 'brak audytu',
'new': f"{new_audit['score']}%",
})
has_changes = len(changes) > 0
# Store pending audit result for later save
# Serialize the AuditResult fields we need to reconstruct it
pending_entry = {
'company_id': company_id,
'company_name': company_name,
'old_score': old_audit['score'] if old_audit else None,
'new_score': new_audit['score'],
'changes': changes,
'has_changes': has_changes,
# Store full result data for saving on approve
'audit_data': {
'completeness_score': result.completeness_score,
'review_count': result.review_count,
'average_rating': float(result.average_rating) if result.average_rating else None,
'photo_count': result.photo_count,
'logo_present': result.logo_present,
'cover_photo_present': result.cover_photo_present,
'google_place_id': result.google_place_id,
'google_maps_url': result.google_maps_url,
'has_name': new_audit['has_name'],
'has_address': new_audit['has_address'],
'has_phone': new_audit['has_phone'],
'has_website': new_audit['has_website'],
'has_hours': new_audit['has_hours'],
'has_categories': new_audit['has_categories'],
'has_photos': new_audit['has_photos'],
'has_description': new_audit['has_description'],
'has_services': new_audit['has_services'],
'has_reviews': new_audit['has_reviews'],
},
}
state['pending_changes'].append(pending_entry)
state['results'].append({
'company_id': company_id,
'company_name': company_name,
'score': result.completeness_score,
'status': 'ok',
'old_score': old_audit['score'] if old_audit else None,
'status': 'changes' if has_changes else 'no_changes',
'changes_count': len(changes),
})
except Exception as e:
logger.error(f"GBP batch audit failed for company {company_id}: {e}")
@ -744,6 +886,8 @@ def admin_gbp_audit_run_batch():
'completed': 0,
'errors': 0,
'results': [],
'pending_changes': [],
'approved': False,
})
thread = threading.Thread(
@ -779,6 +923,140 @@ def admin_gbp_audit_batch_status():
'errors': state.get('errors', 0),
'results': new_results,
'results_total': len(results),
'pending_count': len(state.get('pending_changes', [])),
})
@bp.route('/gbp-audit/batch-review')
@login_required
@role_required(SystemRole.ADMIN)
def admin_gbp_audit_batch_review():
"""Review pending GBP batch audit changes before saving."""
if not is_audit_owner():
abort(404)
state = _read_gbp_batch_state()
if state.get('running'):
flash('Audyt jeszcze trwa. Poczekaj na zakończenie.', 'warning')
return redirect(url_for('admin.admin_gbp_audit'))
pending = state.get('pending_changes', [])
results = state.get('results', [])
approved = state.get('approved', False)
# Summary stats
with_changes = [p for p in pending if p.get('has_changes')]
without_changes = [p for p in pending if not p.get('has_changes')]
errors = [r for r in results if r.get('status') == 'error']
summary = {
'total_companies': len(pending) + len(errors),
'with_changes': len(with_changes),
'without_changes': len(without_changes),
'errors': len(errors),
}
return render_template('admin/gbp_audit_batch_review.html',
pending=with_changes,
no_changes=without_changes,
errors=errors,
summary=summary,
approved=approved,
)
@bp.route('/gbp-audit/batch-approve', methods=['POST'])
@login_required
@role_required(SystemRole.ADMIN)
def admin_gbp_audit_batch_approve():
"""Approve and save pending GBP audit results to database."""
if not is_audit_owner():
return jsonify({'error': 'Brak uprawnień'}), 403
state = _read_gbp_batch_state()
if state.get('running'):
return jsonify({'error': 'Audyt jeszcze trwa'}), 409
if state.get('approved'):
return jsonify({'error': 'Wyniki już zostały zatwierdzone'}), 409
pending = state.get('pending_changes', [])
if not pending:
return jsonify({'error': 'Brak wyników do zatwierdzenia'}), 400
db = SessionLocal()
applied = 0
errors = 0
try:
for entry in pending:
try:
data = entry['audit_data']
audit = GBPAudit(
company_id=entry['company_id'],
completeness_score=data['completeness_score'],
review_count=data.get('review_count', 0),
average_rating=data.get('average_rating'),
photo_count=data.get('photo_count', 0),
logo_present=data.get('logo_present', False),
cover_photo_present=data.get('cover_photo_present', False),
google_place_id=data.get('google_place_id'),
google_maps_url=data.get('google_maps_url'),
has_name=data.get('has_name', False),
has_address=data.get('has_address', False),
has_phone=data.get('has_phone', False),
has_website=data.get('has_website', False),
has_hours=data.get('has_hours', False),
has_categories=data.get('has_categories', False),
has_photos=data.get('has_photos', False),
has_description=data.get('has_description', False),
has_services=data.get('has_services', False),
has_reviews=data.get('has_reviews', False),
source='automated',
audit_date=datetime.now(),
)
db.add(audit)
applied += 1
except Exception as e:
logger.error(f"Failed to save GBP audit for company {entry.get('company_id')}: {e}")
errors += 1
db.commit()
# Mark as approved in state
state['approved'] = True
_write_gbp_batch_state(state)
return jsonify({
'status': 'approved',
'applied': applied,
'errors': errors,
'message': f'Zapisano {applied} audytów do bazy danych.',
})
except Exception as e:
db.rollback()
logger.error(f"GBP batch approve failed: {e}")
return jsonify({'error': f'Błąd zapisu: {str(e)[:100]}'}), 500
finally:
db.close()
@bp.route('/gbp-audit/batch-discard', methods=['POST'])
@login_required
@role_required(SystemRole.ADMIN)
def admin_gbp_audit_batch_discard():
"""Discard pending GBP batch audit results without saving."""
if not is_audit_owner():
return jsonify({'error': 'Brak uprawnień'}), 403
state = _read_gbp_batch_state()
count = len(state.get('pending_changes', []))
_write_gbp_batch_state(dict(_GBP_BATCH_DEFAULT))
return jsonify({
'status': 'discarded',
'count': count,
'message': f'Odrzucono {count} wyników audytu.',
})

View File

@ -0,0 +1,379 @@
{% extends "base.html" %}
{% block title %}Przegląd audytu GBP - 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: center;
margin-bottom: var(--spacing-xl); flex-wrap: wrap; gap: var(--spacing-md);
}
.review-header h1 { font-size: var(--font-size-2xl); color: var(--text-primary); }
.back-link {
display: inline-flex; align-items: center; gap: var(--spacing-xs);
color: var(--text-secondary); text-decoration: none; font-size: var(--font-size-sm);
margin-bottom: var(--spacing-md);
}
.back-link:hover { color: var(--primary); }
.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: white; padding: var(--spacing-lg); 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;
margin-bottom: var(--spacing-xs);
}
.review-stat-value.blue { color: var(--primary); }
.review-stat-value.green { color: var(--success); }
.review-stat-value.gray { color: var(--secondary); }
.review-stat-value.red { color: var(--error); }
.review-stat-label { color: var(--text-secondary); font-size: var(--font-size-sm); }
/* Approval banner */
.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 #fbbf24; color: #92400e; }
.approval-banner.approved { background: #dcfce7; border: 1px solid #86efac; color: #166534; }
.approval-banner.empty { background: #f3f4f6; border: 1px solid #d1d5db; color: var(--text-secondary); }
.approval-banner strong { font-weight: 600; }
/* Company cards */
.company-review {
background: white; border-radius: var(--radius-lg); box-shadow: var(--shadow-sm);
margin-bottom: var(--spacing-md); overflow: hidden; border: 1px solid var(--border-color);
}
.company-review-header {
display: flex; justify-content: space-between; align-items: center;
padding: var(--spacing-md) var(--spacing-lg); cursor: pointer; user-select: none;
}
.company-review-header:hover { background: #f9fafb; }
.company-review-title {
font-weight: 600; color: var(--text-primary); display: flex;
align-items: center; gap: var(--spacing-sm);
}
.company-review-meta {
display: flex; align-items: center; gap: var(--spacing-md);
font-size: var(--font-size-sm); color: var(--text-secondary);
}
.score-change {
display: inline-flex; align-items: center; gap: 4px; font-weight: 600;
}
.score-change.up { color: var(--success); }
.score-change.down { color: var(--error); }
.score-change.same { color: var(--text-muted); }
.score-change.new { color: var(--primary); }
.toggle-arrow { transition: transform 0.2s; }
.toggle-arrow.open { transform: rotate(180deg); }
.company-review-body { padding: 0 var(--spacing-lg) var(--spacing-lg); }
.company-review-body.hidden { display: none; }
.changes-table { width: 100%; border-collapse: collapse; font-size: var(--font-size-sm); }
.changes-table th {
text-align: left; padding: var(--spacing-sm) var(--spacing-md);
background: #f9fafb; color: var(--text-secondary); font-weight: 500;
border-bottom: 1px solid var(--border-color);
}
.changes-table td {
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid #f3f4f6;
}
.old-val { color: var(--error); text-decoration: line-through; }
.new-val { color: var(--success); font-weight: 600; }
.badge {
display: inline-block; padding: 2px 8px; border-radius: var(--radius-sm);
font-size: 11px; font-weight: 600;
}
.badge.changes { background: #dbeafe; color: #2563eb; }
.badge.error { background: #fee2e2; color: #dc2626; }
.badge.no-changes { background: #f3f4f6; color: var(--text-secondary); }
/* No changes section */
.no-changes-section {
background: white; border-radius: var(--radius-lg); box-shadow: var(--shadow-sm);
padding: var(--spacing-lg); margin-bottom: var(--spacing-xl);
}
.no-changes-section h3 { margin-bottom: var(--spacing-md); color: var(--text-secondary); }
.no-changes-pills {
display: flex; flex-wrap: wrap; gap: var(--spacing-xs);
}
.no-changes-pill {
padding: 4px 10px; background: #f3f4f6; border-radius: var(--radius-sm);
font-size: var(--font-size-xs); color: var(--text-secondary);
}
/* Sticky bottom bar */
.bottom-action-bar {
position: sticky; bottom: 0; background: white; border-top: 1px solid var(--border-color);
padding: var(--spacing-md) var(--spacing-lg); display: flex;
justify-content: flex-end; gap: var(--spacing-sm); box-shadow: 0 -4px 12px rgba(0,0,0,0.08);
z-index: 100;
}
/* Confirm modal */
.modal-overlay {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); z-index: 9999; align-items: center; justify-content: center;
}
.modal-overlay.active { display: flex; }
.modal-content {
background: white; border-radius: var(--radius-lg); padding: var(--spacing-xl);
max-width: 480px; width: 90%; box-shadow: var(--shadow-lg);
}
.modal-content h3 { margin-bottom: var(--spacing-md); }
.modal-content p { color: var(--text-secondary); margin-bottom: var(--spacing-lg); }
.modal-buttons { display: flex; gap: var(--spacing-sm); justify-content: flex-end; }
</style>
{% endblock %}
{% block content %}
<div class="review-page">
<a href="{{ url_for('admin.admin_gbp_audit') }}" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M15 19l-7-7 7-7"/>
</svg>
Powrót do panelu GBP
</a>
<div class="review-header">
<h1>Przegląd wyników audytu GBP</h1>
{% if not approved and summary.with_changes > 0 %}
<div style="display:flex; gap:var(--spacing-sm);">
<button class="btn btn-outline btn-sm" onclick="discardChanges()">Odrzuć wszystko</button>
<button class="btn btn-primary btn-sm" id="approveBtn" onclick="approveChanges()">
Zatwierdź i zapisz ({{ summary.with_changes }})
</button>
</div>
{% endif %}
</div>
<!-- Summary -->
<div class="review-summary">
<div class="review-stat">
<span class="review-stat-value blue">{{ summary.total_companies }}</span>
<span class="review-stat-label">Firm zbadanych</span>
</div>
<div class="review-stat">
<span class="review-stat-value green">{{ summary.with_changes }}</span>
<span class="review-stat-label">Ze zmianami</span>
</div>
<div class="review-stat">
<span class="review-stat-value gray">{{ summary.without_changes }}</span>
<span class="review-stat-label">Bez zmian</span>
</div>
<div class="review-stat">
<span class="review-stat-value red">{{ summary.errors }}</span>
<span class="review-stat-label">Błędy</span>
</div>
</div>
<!-- Approval banner -->
{% if approved %}
<div class="approval-banner approved">
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<strong>Wyniki zostały zatwierdzone i zapisane do bazy danych.</strong>
</div>
{% elif summary.with_changes > 0 %}
<div class="approval-banner pending">
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
<div>
<strong>{{ summary.with_changes }} {{ 'firma ma' if summary.with_changes == 1 else 'firm ma' }} zmiany do zatwierdzenia.</strong>
Przejrzyj różnice poniżej i zatwierdź lub odrzuć wyniki.
</div>
</div>
{% else %}
<div class="approval-banner empty">
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<strong>Brak zmian do zatwierdzenia. Wszystkie audyty są aktualne.</strong>
</div>
{% endif %}
<!-- Companies with changes -->
{% for entry in pending %}
<div class="company-review">
<div class="company-review-header" onclick="toggleCompany(this)">
<div class="company-review-title">
{{ entry.company_name }}
<span class="badge changes">{{ entry.changes|length }} {{ 'zmiana' if entry.changes|length == 1 else 'zmian' }}</span>
</div>
<div class="company-review-meta">
{% if entry.old_score is not none and entry.new_score is not none %}
{% set diff = entry.new_score - entry.old_score %}
<span class="score-change {{ 'up' if diff > 0 else ('down' if diff < 0 else 'same') }}">
{{ entry.old_score }}% &rarr; {{ entry.new_score }}%
{% if diff > 0 %}(+{{ diff }}){% elif diff < 0 %}({{ diff }}){% endif %}
</span>
{% elif entry.old_score is none %}
<span class="score-change new">Nowy: {{ entry.new_score }}%</span>
{% endif %}
<svg class="toggle-arrow" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 9l-7 7-7-7"/></svg>
</div>
</div>
<div class="company-review-body hidden">
<table class="changes-table">
<thead>
<tr>
<th>Pole</th>
<th>Poprzednia wartość</th>
<th>Nowa wartość</th>
</tr>
</thead>
<tbody>
{% for change in entry.changes %}
<tr>
<td>{{ change.label }}</td>
<td><span class="old-val">{{ change.old }}</span></td>
<td><span class="new-val">{{ change.new }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
<!-- Errors -->
{% if errors %}
<h3 style="margin: var(--spacing-xl) 0 var(--spacing-md); color: var(--error);">Błędy ({{ errors|length }})</h3>
{% for err in errors %}
<div class="company-review" style="border-left: 3px solid var(--error);">
<div class="company-review-header" style="cursor:default;">
<div class="company-review-title">
{{ err.company_name }}
<span class="badge error">Błąd</span>
</div>
<div class="company-review-meta" style="color:var(--error);">{{ err.error }}</div>
</div>
</div>
{% endfor %}
{% endif %}
<!-- No changes section -->
{% if no_changes %}
<div class="no-changes-section">
<h3>Bez zmian ({{ no_changes|length }})</h3>
<div class="no-changes-pills">
{% for entry in no_changes %}
<span class="no-changes-pill">{{ entry.company_name }} ({{ entry.new_score }}%)</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Sticky bottom bar -->
{% if not approved and summary.with_changes > 0 %}
<div class="bottom-action-bar">
<button class="btn btn-outline btn-sm" onclick="discardChanges()">Odrzuć wszystko</button>
<button class="btn btn-primary btn-sm" id="approveBtn2" onclick="approveChanges()">
Zatwierdź i zapisz ({{ summary.with_changes }})
</button>
</div>
{% endif %}
</div>
<!-- Confirm modal -->
<div class="modal-overlay" id="confirmModal" onclick="if(event.target===this)closeModal()">
<div class="modal-content">
<h3 id="modalTitle"></h3>
<p id="modalMessage"></p>
<div class="modal-buttons" id="modalButtons"></div>
</div>
</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 showModal(title, message, confirmText, confirmClass, onConfirm) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalMessage').textContent = message;
var buttons = document.getElementById('modalButtons');
buttons.innerHTML = '<button class="btn btn-outline btn-sm" onclick="closeModal()">Anuluj</button>' +
'<button class="btn ' + confirmClass + ' btn-sm" onclick="closeModal();(' + onConfirm.name + ')()">' + confirmText + '</button>';
document.getElementById('confirmModal').classList.add('active');
}
function closeModal() {
document.getElementById('confirmModal').classList.remove('active');
}
function approveChanges() {
showModal(
'Zatwierdź wyniki audytu',
'Czy na pewno chcesz zapisać {{ summary.with_changes }} wyników audytu GBP do bazy danych? Tej operacji nie można cofnąć.',
'Zatwierdź i zapisz', 'btn-primary', doApprove
);
}
function doApprove() {
var btns = document.querySelectorAll('#approveBtn, #approveBtn2');
btns.forEach(function(b) { b.disabled = true; b.textContent = 'Zapisywanie...'; });
fetch('{{ url_for("admin.admin_gbp_audit_batch_approve") }}', {
method: 'POST',
headers: {'X-CSRFToken': '{{ csrf_token() }}'}
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'approved') {
window.location.reload();
} else {
alert(data.error || 'Błąd zatwierdzania');
btns.forEach(function(b) { b.disabled = false; b.textContent = 'Zatwierdź i zapisz'; });
}
})
.catch(function(e) {
alert('Błąd połączenia: ' + e.message);
btns.forEach(function(b) { b.disabled = false; b.textContent = 'Zatwierdź i zapisz'; });
});
}
function discardChanges() {
showModal(
'Odrzuć wyniki',
'Czy na pewno chcesz odrzucić wszystkie wyniki audytu? Żadne dane nie zostaną zapisane.',
'Odrzuć', 'btn-outline', doDiscard
);
}
function doDiscard() {
fetch('{{ url_for("admin.admin_gbp_audit_batch_discard") }}', {
method: 'POST',
headers: {'X-CSRFToken': '{{ csrf_token() }}'}
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'discarded') {
window.location.href = '{{ url_for("admin.admin_gbp_audit") }}';
} else {
alert(data.error || 'Błąd odrzucania');
}
})
.catch(function(e) { alert('Błąd połączenia: ' + e.message); });
}
{% endblock %}

View File

@ -892,11 +892,16 @@ function pollGbpBatch() {
var line = document.createElement('div');
line.style.padding = '3px 0';
line.style.borderBottom = '1px solid #f3f4f6';
if (r.status === 'ok') {
if (r.status === 'changes' || r.status === 'no_changes' || r.status === 'ok') {
var scoreColor = r.score >= 90 ? '#166534' : (r.score >= 70 ? '#92400e' : '#991b1b');
var scoreBg = r.score >= 90 ? '#dcfce7' : (r.score >= 70 ? '#fef3c7' : '#fee2e2');
var changeInfo = '';
if (r.status === 'changes') {
var diff = r.old_score !== null ? (r.score - r.old_score) : null;
changeInfo = diff !== null ? (' <span style="color:' + (diff > 0 ? '#22c55e' : '#dc2626') + '; font-size:11px;">(' + (diff > 0 ? '+' : '') + diff + ')</span>') : ' <span style="color:#2563eb; font-size:11px;">nowy</span>';
}
line.innerHTML = '<span style="color:#22c55e;">&#10003;</span> ' + r.company_name +
' <span style="background:' + scoreBg + '; color:' + scoreColor + '; padding:1px 6px; border-radius:3px; font-size:11px;">' + r.score + '%</span>';
' <span style="background:' + scoreBg + '; color:' + scoreColor + '; padding:1px 6px; border-radius:3px; font-size:11px;">' + r.score + '%</span>' + changeInfo;
} else {
line.innerHTML = '<span style="color:#dc2626;">&#10007;</span> ' + r.company_name +
' <span style="color:#dc2626; font-size:11px;">' + (r.error || 'blad') + '</span>';
@ -912,9 +917,17 @@ function pollGbpBatch() {
// Completed
document.getElementById('gbpBatchSpinner').style.animation = 'none';
document.getElementById('gbpBatchSpinner').style.borderColor = '#22c55e';
document.getElementById('gbpBatchTitle').textContent = 'Audyt zakonczony — ' + data.completed + ' firm sprawdzonych';
document.getElementById('gbpBatchTitle').style.color = '#166534';
document.getElementById('gbpBatchSubtitle').textContent = data.errors > 0 ? (data.errors + ' bledow') : 'Wszystkie firmy zbadane pomyslnie';
var pendingCount = data.pending_count || 0;
if (pendingCount > 0) {
document.getElementById('gbpBatchTitle').innerHTML = 'Audyt zakończony — <a href="{{ url_for("admin.admin_gbp_audit_batch_review") }}" style="color:#2563eb; text-decoration:underline;">Przejrzyj ' + pendingCount + ' wyników przed zapisem</a>';
document.getElementById('gbpBatchTitle').style.color = '#1e40af';
document.getElementById('gbpBatchSubtitle').textContent = 'Dane NIE zostały jeszcze zapisane — wymagają Twojej akceptacji';
} else {
document.getElementById('gbpBatchTitle').textContent = 'Audyt zakończony — brak zmian do zatwierdzenia';
document.getElementById('gbpBatchTitle').style.color = '#166534';
document.getElementById('gbpBatchSubtitle').textContent = 'Wszystkie firmy zbadane pomyślnie';
}
var btn = document.getElementById('gbpBatchBtn');
btn.disabled = false;