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
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:
parent
753a84dff2
commit
1d39c9190a
@ -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.',
|
||||
})
|
||||
|
||||
|
||||
|
||||
379
templates/admin/gbp_audit_batch_review.html
Normal file
379
templates/admin/gbp_audit_batch_review.html
Normal 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 }}% → {{ 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 %}
|
||||
@ -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;">✓</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;">✗</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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user